From c5af51c59dcd21c09ec2d6d3eb7a5d35faef0c28 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 26 Feb 2024 15:19:27 +0100 Subject: [PATCH 001/141] docs(intro): fix grammar mistake in docs (#29659) Signed-off-by: Simon Knott --- docs/src/ci-intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ci-intro.md b/docs/src/ci-intro.md index da4f31522f..f5393cfc9e 100644 --- a/docs/src/ci-intro.md +++ b/docs/src/ci-intro.md @@ -8,7 +8,7 @@ title: "CI GitHub Actions" Playwright tests can be run on any CI provider. In this section we will cover running tests on GitHub using GitHub actions. If you would like to see how to configure other CI providers check out our detailed [doc on Continuous Integration](./ci.md). -When [installing Playwright](./intro.md) using the [VS Code extension](./getting-started-vscode.md) or with `npm init playwright@latest` you are given the option to add a [GitHub Actions](https://docs.github.com/en/actions). This creates a `playwright.yml` file inside a `.github/workflows` folder containing everything you need so that your tests run on each push and pull request into the main/master branch. +When [installing Playwright](./intro.md) using the [VS Code extension](./getting-started-vscode.md) or with `npm init playwright@latest` you are given the option to add a [GitHub Actions](https://docs.github.com/en/actions) workflow. This creates a `playwright.yml` file inside a `.github/workflows` folder containing everything you need so that your tests run on each push and pull request into the main/master branch. #### You will learn * langs: js From 7eb910a652f0e950507502981f4ec349ee8ef0b3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 26 Feb 2024 09:39:21 -0800 Subject: [PATCH 002/141] chore: enable eslint for js (#29639) --- .eslintignore | 3 ++- .eslintrc.js | 1 + package.json | 2 +- packages/playwright-ct-react/registerSource.mjs | 10 ++++------ packages/playwright-ct-react17/registerSource.mjs | 11 ++++------- packages/playwright-ct-svelte/registerSource.mjs | 4 ++-- packages/playwright/.eslintrc.js | 9 --------- tsconfig.json | 6 ------ 8 files changed, 14 insertions(+), 32 deletions(-) diff --git a/.eslintignore b/.eslintignore index f7365e0082..152cbbe228 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,4 +19,5 @@ tests/components/ tests/installation/fixture-scripts/ examples/ DEPS -.cache/ \ No newline at end of file +.cache/ +utils/ diff --git a/.eslintrc.js b/.eslintrc.js index 7bc5a0868f..ab0ffeecee 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { parserOptions: { ecmaVersion: 9, sourceType: "module", + project: "./tsconfig.json", }, extends: [ "plugin:react-hooks/recommended" diff --git a/package.json b/package.json index 89550ec4e4..e62801990d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts", "ct": "playwright test tests/components/test-all.spec.js --reporter=list", "test": "playwright test --config=tests/library/playwright.config.ts", - "eslint": "eslint --cache --report-unused-disable-directives --ext ts,tsx .", + "eslint": "eslint --cache --report-unused-disable-directives --ext ts,tsx,js,jsx,mjs .", "tsc": "tsc -p .", "build-installer": "babel -s --extensions \".ts\" --out-dir packages/playwright-core/lib/utils/ packages/playwright-core/src/utils", "doc": "node utils/doclint/cli.js", diff --git a/packages/playwright-ct-react/registerSource.mjs b/packages/playwright-ct-react/registerSource.mjs index 10dad4c0db..9ad2612bc4 100644 --- a/packages/playwright-ct-react/registerSource.mjs +++ b/packages/playwright-ct-react/registerSource.mjs @@ -40,13 +40,11 @@ function __pwRender(value) { if (isJsxComponent(v)) { const component = v; const props = component.props ? __pwRender(component.props) : {}; - const {children, ...propsWithoutChildren} = props; - /** @type {[any, any, any?]} */ - const createElementArguments = [component.type, propsWithoutChildren]; - if(children){ + const { children, ...propsWithoutChildren } = props; + const createElementArguments = [propsWithoutChildren]; + if (children) createElementArguments.push(children); - } - return { result: __pwReact.createElement(...createElementArguments) }; + return { result: __pwReact.createElement(component.type, ...createElementArguments) }; } }); } diff --git a/packages/playwright-ct-react17/registerSource.mjs b/packages/playwright-ct-react17/registerSource.mjs index 8dfc1d24e9..158984f3ac 100644 --- a/packages/playwright-ct-react17/registerSource.mjs +++ b/packages/playwright-ct-react17/registerSource.mjs @@ -40,14 +40,11 @@ function __pwRender(value) { if (isJsxComponent(v)) { const component = v; const props = component.props ? __pwRender(component.props) : {}; - - const {children, ...propsWithoutChildren} = props; - /** @type {[any, any, any?]} */ - const createElementArguments = [component.type, propsWithoutChildren]; - if(children){ + const { children, ...propsWithoutChildren } = props; + const createElementArguments = [propsWithoutChildren]; + if (children) createElementArguments.push(children); - } - return { result: __pwReact.createElement(...createElementArguments) }; + return { result: __pwReact.createElement(component.type, ...createElementArguments) }; } }); } diff --git a/packages/playwright-ct-svelte/registerSource.mjs b/packages/playwright-ct-svelte/registerSource.mjs index 4552a4ba0a..b11f8e0369 100644 --- a/packages/playwright-ct-svelte/registerSource.mjs +++ b/packages/playwright-ct-svelte/registerSource.mjs @@ -42,8 +42,8 @@ function __pwCreateSlots(slots) { for (const slotName in slots) { const template = document - .createRange() - .createContextualFragment(slots[slotName]); + .createRange() + .createContextualFragment(slots[slotName]); svelteSlots[slotName] = [createSlotFn(template)]; } diff --git a/packages/playwright/.eslintrc.js b/packages/playwright/.eslintrc.js index 67c9b6313d..71985134bd 100644 --- a/packages/playwright/.eslintrc.js +++ b/packages/playwright/.eslintrc.js @@ -1,14 +1,5 @@ -const path = require('path'); - module.exports = { extends: '../.eslintrc.js', - parser: "@typescript-eslint/parser", - plugins: ["@typescript-eslint", "notice"], - parserOptions: { - ecmaVersion: 9, - sourceType: "module", - project: path.join(__dirname, '..', '..', 'tsconfig.json'), - }, rules: { '@typescript-eslint/no-floating-promises': 'error', }, diff --git a/tsconfig.json b/tsconfig.json index 733973760a..56d9996996 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,11 +35,5 @@ "include": ["packages"], "exclude": [ "packages/*/lib", - "packages/playwright-ct-react", - "packages/playwright-ct-react17", - "packages/playwright-ct-solid", - "packages/playwright-ct-svelte", - "packages/playwright-ct-vue", - "packages/playwright-ct-vue2" ], } From 7e502e91b2cf1148f186502649424074a8b35ccc Mon Sep 17 00:00:00 2001 From: Sander Date: Mon, 26 Feb 2024 20:15:08 +0100 Subject: [PATCH 003/141] fix(ct): solid pass children when they are defined (#29648) --- .../playwright-ct-solid/registerSource.mjs | 48 +++++-------------- .../src/components/CheckChildrenProp.tsx | 17 ++----- .../ct-react-vite/tests/children.spec.tsx | 2 +- .../src/components/CheckChildrenProp.tsx | 17 ++----- .../ct-react17/tests/children.spec.tsx | 2 +- .../src/components/CheckChildrenProp.tsx | 7 +++ .../ct-solid/tests/children.spec.tsx | 6 +++ 7 files changed, 35 insertions(+), 64 deletions(-) create mode 100644 tests/components/ct-solid/src/components/CheckChildrenProp.tsx diff --git a/packages/playwright-ct-solid/registerSource.mjs b/packages/playwright-ct-solid/registerSource.mjs index a76792c513..c89caa7bd8 100644 --- a/packages/playwright-ct-solid/registerSource.mjs +++ b/packages/playwright-ct-solid/registerSource.mjs @@ -19,9 +19,7 @@ import { render as __pwSolidRender, createComponent as __pwSolidCreateComponent } from 'solid-js/web'; import __pwH from 'solid-js/h'; - /** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */ -/** @typedef {() => import('solid-js').JSX.Element} FrameworkComponent */ /** * @param {any} component @@ -32,42 +30,20 @@ function isJsxComponent(component) { } /** - * @param {any} child + * @param {any} value */ -function __pwCreateChild(child) { - if (Array.isArray(child)) - return child.map(grandChild => __pwCreateChild(grandChild)); - if (isJsxComponent(child)) - return __pwCreateComponent(child); - return child; -} - -/** - * @param {JsxComponent} component - * @returns {any[] | undefined} - */ -function __pwJsxChildArray(component) { - if (!component.props.children) - return; - if (Array.isArray(component.props.children)) - return component.props.children; - return [component.props.children]; -} - -/** - * @param {JsxComponent} component - */ -function __pwCreateComponent(component) { - const children = __pwJsxChildArray(component)?.map(child => __pwCreateChild(child)).filter(child => { - if (typeof child === 'string') - return !!child.trim(); - return true; +function __pwCreateComponent(value) { + return window.__pwTransformObject(value, v => { + if (isJsxComponent(v)) { + const component = v; + const props = component.props ? __pwCreateComponent(component.props) : {}; + if (typeof component.type === 'string') { + const { children, ...propsWithoutChildren } = props; + return { result: __pwH(component.type, propsWithoutChildren, children) }; + } + return { result: __pwSolidCreateComponent(component.type, props) }; + } }); - - if (typeof component.type === 'string') - return __pwH(component.type, component.props, children); - - return __pwSolidCreateComponent(component.type, { ...component.props, children }); } const __pwUnmountKey = Symbol('unmountKey'); diff --git a/tests/components/ct-react-vite/src/components/CheckChildrenProp.tsx b/tests/components/ct-react-vite/src/components/CheckChildrenProp.tsx index 42b3a361e8..3e8f405a5b 100644 --- a/tests/components/ct-react-vite/src/components/CheckChildrenProp.tsx +++ b/tests/components/ct-react-vite/src/components/CheckChildrenProp.tsx @@ -1,16 +1,7 @@ -type DefaultChildrenProps = { - children?: any; -} +import type { PropsWithChildren } from 'react'; + +type DefaultChildrenProps = PropsWithChildren<{}>; export default function CheckChildrenProp(props: DefaultChildrenProps) { - const content = 'children' in props ? props.children : 'No Children'; - return
-

Welcome!

-
- {content} -
-
- Thanks for visiting. -
-
+ return <>{'children' in props ? props.children : 'No Children'} } diff --git a/tests/components/ct-react-vite/tests/children.spec.tsx b/tests/components/ct-react-vite/tests/children.spec.tsx index d671b14e64..03da7d2c55 100644 --- a/tests/components/ct-react-vite/tests/children.spec.tsx +++ b/tests/components/ct-react-vite/tests/children.spec.tsx @@ -60,7 +60,7 @@ test('render number as child', async ({ mount }) => { await expect(component).toContainText('1337'); }); -test('render without children', async ({ mount }) => { +test('absence of children when children prop is not provided', async ({ mount }) => { const component = await mount(); await expect(component).toContainText('No Children'); }); diff --git a/tests/components/ct-react17/src/components/CheckChildrenProp.tsx b/tests/components/ct-react17/src/components/CheckChildrenProp.tsx index 42b3a361e8..3e8f405a5b 100644 --- a/tests/components/ct-react17/src/components/CheckChildrenProp.tsx +++ b/tests/components/ct-react17/src/components/CheckChildrenProp.tsx @@ -1,16 +1,7 @@ -type DefaultChildrenProps = { - children?: any; -} +import type { PropsWithChildren } from 'react'; + +type DefaultChildrenProps = PropsWithChildren<{}>; export default function CheckChildrenProp(props: DefaultChildrenProps) { - const content = 'children' in props ? props.children : 'No Children'; - return
-

Welcome!

-
- {content} -
-
- Thanks for visiting. -
-
+ return <>{'children' in props ? props.children : 'No Children'} } diff --git a/tests/components/ct-react17/tests/children.spec.tsx b/tests/components/ct-react17/tests/children.spec.tsx index 32ddabe7ac..8f55237723 100644 --- a/tests/components/ct-react17/tests/children.spec.tsx +++ b/tests/components/ct-react17/tests/children.spec.tsx @@ -60,7 +60,7 @@ test('render number as child', async ({ mount }) => { await expect(component).toContainText('1337'); }); -test('render without children', async ({ mount }) => { +test('absence of children when children prop is not provided', async ({ mount }) => { const component = await mount(); await expect(component).toContainText('No Children'); }); diff --git a/tests/components/ct-solid/src/components/CheckChildrenProp.tsx b/tests/components/ct-solid/src/components/CheckChildrenProp.tsx new file mode 100644 index 0000000000..113a44ced9 --- /dev/null +++ b/tests/components/ct-solid/src/components/CheckChildrenProp.tsx @@ -0,0 +1,7 @@ +import { type ParentProps } from 'solid-js'; + +type DefaultChildrenProps = ParentProps<{}>; + +export default function CheckChildrenProp(props: DefaultChildrenProps) { + return <>{'children' in props ? props.children : 'No Children'} +} diff --git a/tests/components/ct-solid/tests/children.spec.tsx b/tests/components/ct-solid/tests/children.spec.tsx index 75515f754e..ebf80fe803 100644 --- a/tests/components/ct-solid/tests/children.spec.tsx +++ b/tests/components/ct-solid/tests/children.spec.tsx @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-solid'; import Button from '@/components/Button'; import DefaultChildren from '@/components/DefaultChildren'; import MultipleChildren from '@/components/MultipleChildren'; +import CheckChildrenProp from '@/components/CheckChildrenProp' test('render a default child', async ({ mount }) => { const component = await mount( @@ -58,3 +59,8 @@ test('render number as child', async ({ mount }) => { const component = await mount({1337}); await expect(component).toContainText('1337'); }); + +test('absence of children when children prop is not provided', async ({ mount }) => { + const component = await mount(); + await expect(component).toContainText('No Children'); +}); From 303d7fdac9971312d7314a151c7e33329b06b660 Mon Sep 17 00:00:00 2001 From: Sander Date: Mon, 26 Feb 2024 20:15:55 +0100 Subject: [PATCH 004/141] chore(ct): vue resolve internal type errors (#29649) --- packages/playwright-ct-vue/registerSource.mjs | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/playwright-ct-vue/registerSource.mjs b/packages/playwright-ct-vue/registerSource.mjs index 5256a241a4..198372cc38 100644 --- a/packages/playwright-ct-vue/registerSource.mjs +++ b/packages/playwright-ct-vue/registerSource.mjs @@ -88,6 +88,9 @@ function __pwCreateSlot(html) { }; } +/** + * @param {string | string[]} slot + */ function __pwSlotToFunction(slot) { if (typeof slot === 'string') return __pwCreateSlot(slot)(); @@ -175,7 +178,11 @@ function __pwCreateComponent(component) { return { Component: component.type, props, slots: lastArg, listeners }; } +/** + * @param {any} slots + */ function __pwWrapFunctions(slots) { + /** @type {import('vue').ComponentInternalInstance['slots']} */ const slotsWithRenderFunctions = {}; if (!Array.isArray(slots)) { for (const [key, value] of Object.entries(slots || {})) @@ -198,25 +205,27 @@ function __pwCreateWrapper(component) { return wrapper; } -/** - * @returns {any} - */ -function __pwCreateDevTools() { - return { +function __pwSetDevTools() { + __pwSetDevtoolsHook({ emit(eventType, ...payload) { - if (eventType === 'component:emit') { - const [, componentVM, event, eventArgs] = payload; - for (const [wrapper, listeners] of __pwAllListeners) { - if (wrapper.component !== componentVM) - continue; - const listener = listeners[event]; - if (!listener) - return; - listener(...eventArgs); - } + if (eventType !== 'component:emit') + return; + + const [, componentVM, event, eventArgs] = payload; + for (const [wrapper, listeners] of __pwAllListeners) { + if (wrapper.component !== componentVM) + continue; + const listener = listeners[event]; + if (!listener) + return; + listener(...eventArgs); } - } - }; + }, + on() {}, + off() {}, + once() {}, + appRecords: [] + }, {}); } const __pwAppKey = Symbol('appKey'); @@ -230,7 +239,7 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { return wrapper; } }); - __pwSetDevtoolsHook(__pwCreateDevTools(), {}); + __pwSetDevTools(); for (const hook of window.__pw_hooks_before_mount || []) await hook({ app, hooksConfig }); @@ -242,13 +251,15 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { }; window.playwrightUnmount = async rootElement => { - const app = /** @type {import('vue').App} */ (rootElement[__pwAppKey]); + /** @type {import('vue').App | undefined} */ + const app = rootElement[__pwAppKey]; if (!app) throw new Error('Component was not mounted'); app.unmount(); }; window.playwrightUpdate = async (rootElement, component) => { + /** @type {import('vue').VNode | undefined} */ const wrapper = rootElement[__pwWrapperKey]; if (!wrapper) throw new Error('Component was not mounted'); From 015a1bcc1c0bdcc17409266ccc448b2def320914 Mon Sep 17 00:00:00 2001 From: Sander Date: Mon, 26 Feb 2024 20:16:27 +0100 Subject: [PATCH 005/141] feat(ct): double unmounting component throws error (#29650) --- packages/playwright-ct-solid/registerSource.mjs | 1 + packages/playwright-ct-svelte/registerSource.mjs | 1 + packages/playwright-ct-vue/registerSource.mjs | 1 + packages/playwright-ct-vue2/registerSource.mjs | 1 + tests/components/ct-react-vite/tests/unmount.spec.tsx | 6 ++++++ tests/components/ct-react17/tests/unmount.spec.tsx | 6 ++++++ tests/components/ct-solid/tests/unmount.spec.tsx | 6 ++++++ tests/components/ct-svelte-vite/tests/unmount.spec.ts | 10 ++++++++++ tests/components/ct-svelte/tests/unmount.spec.ts | 10 ++++++++++ .../ct-vue-cli/tests/unmount/unmount.spec.ts | 11 +++++++++++ .../ct-vue-cli/tests/unmount/unmount.spec.tsx | 6 ++++++ .../ct-vue-vite/tests/unmount/unmount.spec.ts | 10 ++++++++++ .../ct-vue-vite/tests/unmount/unmount.spec.tsx | 7 +++++++ .../ct-vue2-cli/tests/unmount/unmount.spec.ts | 10 ++++++++++ .../ct-vue2-cli/tests/unmount/unmount.spec.tsx | 6 ++++++ 15 files changed, 92 insertions(+) diff --git a/packages/playwright-ct-solid/registerSource.mjs b/packages/playwright-ct-solid/registerSource.mjs index c89caa7bd8..d0077dd494 100644 --- a/packages/playwright-ct-solid/registerSource.mjs +++ b/packages/playwright-ct-solid/registerSource.mjs @@ -72,6 +72,7 @@ window.playwrightUnmount = async rootElement => { throw new Error('Component was not mounted'); unmount(); + delete rootElement[__pwUnmountKey]; }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright-ct-svelte/registerSource.mjs b/packages/playwright-ct-svelte/registerSource.mjs index b11f8e0369..642548f18a 100644 --- a/packages/playwright-ct-svelte/registerSource.mjs +++ b/packages/playwright-ct-svelte/registerSource.mjs @@ -108,6 +108,7 @@ window.playwrightUnmount = async rootElement => { if (!svelteComponent) throw new Error('Component was not mounted'); svelteComponent.$destroy(); + delete rootElement[__pwSvelteComponentKey]; }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright-ct-vue/registerSource.mjs b/packages/playwright-ct-vue/registerSource.mjs index 198372cc38..07ce5298f4 100644 --- a/packages/playwright-ct-vue/registerSource.mjs +++ b/packages/playwright-ct-vue/registerSource.mjs @@ -256,6 +256,7 @@ window.playwrightUnmount = async rootElement => { if (!app) throw new Error('Component was not mounted'); app.unmount(); + delete rootElement[__pwAppKey]; }; window.playwrightUpdate = async (rootElement, component) => { diff --git a/packages/playwright-ct-vue2/registerSource.mjs b/packages/playwright-ct-vue2/registerSource.mjs index b0a7ae71dc..19b4d41c08 100644 --- a/packages/playwright-ct-vue2/registerSource.mjs +++ b/packages/playwright-ct-vue2/registerSource.mjs @@ -182,6 +182,7 @@ window.playwrightUnmount = async rootElement => { throw new Error('Component was not mounted'); component.$destroy(); component.$el.remove(); + delete rootElement[instanceKey]; }; window.playwrightUpdate = async (element, options) => { diff --git a/tests/components/ct-react-vite/tests/unmount.spec.tsx b/tests/components/ct-react-vite/tests/unmount.spec.tsx index acc11c4fdb..20374d8b8e 100644 --- a/tests/components/ct-react-vite/tests/unmount.spec.tsx +++ b/tests/components/ct-react-vite/tests/unmount.spec.tsx @@ -17,3 +17,9 @@ test('unmount a multi root component', async ({ mount, page }) => { await expect(page.locator('#root')).not.toContainText('root 1'); await expect(page.locator('#root')).not.toContainText('root 2'); }); + +test('unmount twice throws an error', async ({ mount }) => { + const component = await mount(`); + await page.locator('button').evaluate(button => { + window['clicks'] = 0; + button.addEventListener('click', () => ++window['clicks'], false); + }); + return page; + }; + + const clickInPage = async (page, count) => { + for (let i = 0; i < count; ++i) + await page.locator('button').click(); + }; + + const getClicks = async page => page.evaluate(() => window['clicks']); + + const page1 = await createPage(); + const page2 = await createPage(); + + const CLICK_COUNT = 20; + await Promise.all([ + clickInPage(page1, CLICK_COUNT), + clickInPage(page2, CLICK_COUNT), + ]); + expect(await getClicks(page1)).toBe(CLICK_COUNT); + expect(await getClicks(page2)).toBe(CLICK_COUNT); +}); + it('window.open should use parent tab context', async function({ browser, server }) { const context = await browser.newContext(); const page = await context.newPage(); From 1c8e8bea7e6ae32997434cb963e320fe8d86d3dc Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 26 Feb 2024 17:39:25 -0800 Subject: [PATCH 013/141] chore: roll stable test runner to 1.42.0-beta-1708994059000 (#29672) --- .../stable-test-runner/package-lock.json | 46 +++++++++---------- .../stable-test-runner/package.json | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index 2594cfcffd..f1937fd021 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,15 +5,15 @@ "packages": { "": { "dependencies": { - "@playwright/test": "1.41.0-beta-1705101589000" + "@playwright/test": "1.42.0-beta-1708994059000" } }, "node_modules/@playwright/test": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-tpaEm+ih0PhJdk6yrRtk40I9ahErhGYZT2lR65ZZ6Il7tMnUgzxF5OOw3XLGJ59T29nMyI1+hKGqQnf8nUKkQw==", + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-dfCvXGLsNRTOIRXyU7Cx8EdmMJxpHP3Dg2yD+ACM4GhpcSTJqLD0kwggZY2ziDJNG1sNJ1zN/aDgRqv4G3NREQ==", "dependencies": { - "playwright": "1.41.0-beta-1705101589000" + "playwright": "1.42.0-beta-1708994059000" }, "bin": { "playwright": "cli.js" @@ -36,11 +36,11 @@ } }, "node_modules/playwright": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-3mMpZXmkw+fGIb+wBpvDZ4OEm7L1QYptgJgTdP8/OEitYjV5Q05MRZWbgelF/9ameoRpMz7rbkZTWUh/1UtrIw==", + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-+lMRsLfaLx/Okb3qH9r8eUfDBQ7Vq0eTLehxG9plLorSBQnU1b9kQnmchV3jl2hjm9Oh7vNf/No+vgsRBuj7/Q==", "dependencies": { - "playwright-core": "1.41.0-beta-1705101589000" + "playwright-core": "1.42.0-beta-1708994059000" }, "bin": { "playwright": "cli.js" @@ -53,9 +53,9 @@ } }, "node_modules/playwright-core": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-w3aDw2Kp/ZwAUSqLZdmd+mq6khl3ufb2csM51b85r9t8S4m2JYoz1WHFNGNHqFWHBXSrjSBXzFLNgKUmwizRuA==", + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-mdXcpctceoX0aHouE+ZglhB7JnLO1NtQgTF8MBgL/Wsa3r6jRI8DESCVTmbcazr8QGs9MT9wTKSx8Y70zYkaxw==", "bin": { "playwright-core": "cli.js" }, @@ -66,11 +66,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-tpaEm+ih0PhJdk6yrRtk40I9ahErhGYZT2lR65ZZ6Il7tMnUgzxF5OOw3XLGJ59T29nMyI1+hKGqQnf8nUKkQw==", + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-dfCvXGLsNRTOIRXyU7Cx8EdmMJxpHP3Dg2yD+ACM4GhpcSTJqLD0kwggZY2ziDJNG1sNJ1zN/aDgRqv4G3NREQ==", "requires": { - "playwright": "1.41.0-beta-1705101589000" + "playwright": "1.42.0-beta-1708994059000" } }, "fsevents": { @@ -80,18 +80,18 @@ "optional": true }, "playwright": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-3mMpZXmkw+fGIb+wBpvDZ4OEm7L1QYptgJgTdP8/OEitYjV5Q05MRZWbgelF/9ameoRpMz7rbkZTWUh/1UtrIw==", + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-+lMRsLfaLx/Okb3qH9r8eUfDBQ7Vq0eTLehxG9plLorSBQnU1b9kQnmchV3jl2hjm9Oh7vNf/No+vgsRBuj7/Q==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.41.0-beta-1705101589000" + "playwright-core": "1.42.0-beta-1708994059000" } }, "playwright-core": { - "version": "1.41.0-beta-1705101589000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0-beta-1705101589000.tgz", - "integrity": "sha512-w3aDw2Kp/ZwAUSqLZdmd+mq6khl3ufb2csM51b85r9t8S4m2JYoz1WHFNGNHqFWHBXSrjSBXzFLNgKUmwizRuA==" + "version": "1.42.0-beta-1708994059000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708994059000.tgz", + "integrity": "sha512-mdXcpctceoX0aHouE+ZglhB7JnLO1NtQgTF8MBgL/Wsa3r6jRI8DESCVTmbcazr8QGs9MT9wTKSx8Y70zYkaxw==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index 17ce2477d8..363a340c04 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "1.41.0-beta-1705101589000" + "@playwright/test": "1.42.0-beta-1708994059000" } } From 03902d8e85056ad630441be42a0e518086d685da Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 27 Feb 2024 16:21:35 +0100 Subject: [PATCH 014/141] chore: fix docs roll for functions without args (#29684) --- utils/doclint/documentation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/doclint/documentation.js b/utils/doclint/documentation.js index de35df27ba..a50423996a 100644 --- a/utils/doclint/documentation.js +++ b/utils/doclint/documentation.js @@ -737,7 +737,8 @@ function parseTypeExpression(type) { if (type[i] === '(') { name = type.substring(0, i); const matching = matchingBracket(type.substring(i), '(', ')'); - args = parseTypeExpression(type.substring(i + 1, i + matching - 1)); + const argsString = type.substring(i + 1, i + matching - 1); + args = argsString ? parseTypeExpression(argsString) : null; i = i + matching; if (type[i] === ':') { retType = parseTypeExpression(type.substring(i + 1)); From 1a43adc50655360fcd18484a2a238e8fe8c03421 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 27 Feb 2024 16:40:18 +0100 Subject: [PATCH 015/141] chore: fix docs roll for functions without args follow-up (#29687) --- utils/doclint/documentation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/doclint/documentation.js b/utils/doclint/documentation.js index a50423996a..f0527c4120 100644 --- a/utils/doclint/documentation.js +++ b/utils/doclint/documentation.js @@ -566,7 +566,7 @@ class Type { return type; } - if (parsedType.args) { + if (parsedType.args || parsedType.retType) { const type = new Type('function'); type.args = []; // @ts-ignore From 713fbb5d236bfa925f3ee8d900808b8812122920 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 27 Feb 2024 17:34:16 +0100 Subject: [PATCH 016/141] devops: fix cherry_pick_into_release_branch permissions (#29689) Fixes https://github.com/microsoft/playwright/actions/runs/8067553095/job/22038251156. Signed-off-by: Max Schmitt --- .github/workflows/cherry_pick_into_release_branch.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cherry_pick_into_release_branch.yml b/.github/workflows/cherry_pick_into_release_branch.yml index 6350d7ea0d..f48028b14b 100644 --- a/.github/workflows/cherry_pick_into_release_branch.yml +++ b/.github/workflows/cherry_pick_into_release_branch.yml @@ -12,6 +12,9 @@ on: description: Comma-separated list of commit hashes to cherry-pick required: true +permissions: + contents: write + jobs: roll: runs-on: ubuntu-22.04 From d8c16c1edf59806871928e2969d97461e2a3e8f4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 27 Feb 2024 08:36:03 -0800 Subject: [PATCH 017/141] chore: roll stable runner to 1.42.0-beta-1708998235000 (#29675) --- .../stable-test-runner/package-lock.json | 46 +++++++++---------- .../stable-test-runner/package.json | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index f1937fd021..d19c693595 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,15 +5,15 @@ "packages": { "": { "dependencies": { - "@playwright/test": "1.42.0-beta-1708994059000" + "@playwright/test": "1.42.0-beta-1708998235000" } }, "node_modules/@playwright/test": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-dfCvXGLsNRTOIRXyU7Cx8EdmMJxpHP3Dg2yD+ACM4GhpcSTJqLD0kwggZY2ziDJNG1sNJ1zN/aDgRqv4G3NREQ==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-tPONSh/yGC9dApTJ/aSQ83lqqk0kV6mdlIpJYpmh5LWHyZnl2EcwAqLeNF6GxU2r4aIOMaZPzkEE+WPw/isxWw==", "dependencies": { - "playwright": "1.42.0-beta-1708994059000" + "playwright": "1.42.0-beta-1708998235000" }, "bin": { "playwright": "cli.js" @@ -36,11 +36,11 @@ } }, "node_modules/playwright": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-+lMRsLfaLx/Okb3qH9r8eUfDBQ7Vq0eTLehxG9plLorSBQnU1b9kQnmchV3jl2hjm9Oh7vNf/No+vgsRBuj7/Q==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-Iq85EGhAz0URDVtl9vKDVE0FjvEvPVs/46C/jWR5JpaI9tEsuXYoA1i1f5xA74ZhatskHigpuJYbm2YpwO3mIw==", "dependencies": { - "playwright-core": "1.42.0-beta-1708994059000" + "playwright-core": "1.42.0-beta-1708998235000" }, "bin": { "playwright": "cli.js" @@ -53,9 +53,9 @@ } }, "node_modules/playwright-core": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-mdXcpctceoX0aHouE+ZglhB7JnLO1NtQgTF8MBgL/Wsa3r6jRI8DESCVTmbcazr8QGs9MT9wTKSx8Y70zYkaxw==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-kgr9rFXjX1pakRqXWwUIPlPMOdaUVHDu7vuGSryQOfoIld6CQI39vGkpb6rFiRwU3gpK4nL/UtXTqoxcMff3DA==", "bin": { "playwright-core": "cli.js" }, @@ -66,11 +66,11 @@ }, "dependencies": { "@playwright/test": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-dfCvXGLsNRTOIRXyU7Cx8EdmMJxpHP3Dg2yD+ACM4GhpcSTJqLD0kwggZY2ziDJNG1sNJ1zN/aDgRqv4G3NREQ==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-tPONSh/yGC9dApTJ/aSQ83lqqk0kV6mdlIpJYpmh5LWHyZnl2EcwAqLeNF6GxU2r4aIOMaZPzkEE+WPw/isxWw==", "requires": { - "playwright": "1.42.0-beta-1708994059000" + "playwright": "1.42.0-beta-1708998235000" } }, "fsevents": { @@ -80,18 +80,18 @@ "optional": true }, "playwright": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-+lMRsLfaLx/Okb3qH9r8eUfDBQ7Vq0eTLehxG9plLorSBQnU1b9kQnmchV3jl2hjm9Oh7vNf/No+vgsRBuj7/Q==", + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-Iq85EGhAz0URDVtl9vKDVE0FjvEvPVs/46C/jWR5JpaI9tEsuXYoA1i1f5xA74ZhatskHigpuJYbm2YpwO3mIw==", "requires": { "fsevents": "2.3.2", - "playwright-core": "1.42.0-beta-1708994059000" + "playwright-core": "1.42.0-beta-1708998235000" } }, "playwright-core": { - "version": "1.42.0-beta-1708994059000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708994059000.tgz", - "integrity": "sha512-mdXcpctceoX0aHouE+ZglhB7JnLO1NtQgTF8MBgL/Wsa3r6jRI8DESCVTmbcazr8QGs9MT9wTKSx8Y70zYkaxw==" + "version": "1.42.0-beta-1708998235000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0-beta-1708998235000.tgz", + "integrity": "sha512-kgr9rFXjX1pakRqXWwUIPlPMOdaUVHDu7vuGSryQOfoIld6CQI39vGkpb6rFiRwU3gpK4nL/UtXTqoxcMff3DA==" } } } diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index 363a340c04..bb305f7aa5 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "1.42.0-beta-1708994059000" + "@playwright/test": "1.42.0-beta-1708998235000" } } From 321e9d72c35090e036f3be430ad022b5192fced8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 27 Feb 2024 10:29:49 -0800 Subject: [PATCH 018/141] docs: better addLocatorHandler example in release notes (#29692) --- docs/src/release-notes-js.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 16ad193eba..3320a0d14e 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -13,13 +13,15 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; - New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears: ```js // Setup the handler. -await page.addLocatorHandler(page.getByRole('button', { name: 'Accept all cookies' }), async () => { - await page.getByRole('button', { name: 'Reject all cookies' }).click(); -}); - +await page.addLocatorHandler( + page.getByRole('heading', { name: 'Hej! You are in control of your cookies.' }), + async () => { + await page.getByRole('button', { name: 'Accept all' }).click(); + }); // Write the test as usual. -await page.goto('https://example.com'); -await page.getByRole('button', { name: 'Start here' }).click(); +await page.goto('https://www.ikea.com/'); +await page.getByRole('link', { name: 'Collection of blue and white' }).click(); +await expect(page.getByRole('heading', { name: 'Light and easy' })).toBeVisible(); ``` - `expect(callback).toPass()` timeout can now be configured by `expect.toPass.timeout` option [globally](./api/class-testconfig#test-config-expect) or in [project config](./api/class-testproject#test-project-expect) From 8264bec01e778fa9787352a4158edcc10eb8b1f7 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 27 Feb 2024 11:50:24 -0800 Subject: [PATCH 019/141] fix(ff): stop gap for setInputFiles race (#29696) --- .../playwright-core/src/server/firefox/ffPage.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index d2d2a03412..a93551b8cd 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -544,15 +544,13 @@ export class FFPage implements PageDelegate { } async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { - await Promise.all([ - this._session.send('Page.setFileInputFiles', { - frameId: handle._context.frame._id, - objectId: handle._objectId, - files - }), - handle.dispatchEvent(progress.metadata, 'input'), - handle.dispatchEvent(progress.metadata, 'change') - ]); + await this._session.send('Page.setFileInputFiles', { + frameId: handle._context.frame._id, + objectId: handle._objectId, + files + }); + await handle.dispatchEvent(progress.metadata, 'input'); + await handle.dispatchEvent(progress.metadata, 'change'); } async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { From 772345c83ca53542f2ccd74fe12ccb2dcaf099a2 Mon Sep 17 00:00:00 2001 From: Flo Becker Date: Wed, 28 Feb 2024 13:31:43 +0100 Subject: [PATCH 020/141] fit(ct): remove unused type import (#29706) Signed-off-by: Flo Becker --- packages/playwright-ct-core/types/component.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/playwright-ct-core/types/component.d.ts b/packages/playwright-ct-core/types/component.d.ts index 7502ec2f06..5bd5d7e017 100644 --- a/packages/playwright-ct-core/types/component.d.ts +++ b/packages/playwright-ct-core/types/component.d.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import type { ImportRegistry } from '../src/injected/importRegistry'; - type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonArray; type JsonArray = JsonValue[]; From d48aadac7e4c5800499f8d381d9bdbc84a274aa0 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 28 Feb 2024 14:05:42 -0800 Subject: [PATCH 021/141] fix: point to the right cli program export (#29715) Fixes: https://github.com/microsoft/playwright/issues/29711 --- packages/playwright-chromium/cli.js | 2 +- packages/playwright-firefox/cli.js | 2 +- packages/playwright-webkit/cli.js | 2 +- .../installation/playwright-cli-install-should-work.spec.ts | 6 ++++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/playwright-chromium/cli.js b/packages/playwright-chromium/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-chromium/cli.js +++ b/packages/playwright-chromium/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/packages/playwright-firefox/cli.js b/packages/playwright-firefox/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-firefox/cli.js +++ b/packages/playwright-firefox/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/packages/playwright-webkit/cli.js b/packages/playwright-webkit/cli.js index 86adb86a84..f46d8b409e 100755 --- a/packages/playwright-webkit/cli.js +++ b/packages/playwright-webkit/cli.js @@ -15,5 +15,5 @@ * limitations under the License. */ -const { program } = require('playwright-core/lib/program'); +const { program } = require('playwright-core/lib/cli/program'); program.parse(process.argv); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index c6d84cedae..e056e61138 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -89,3 +89,9 @@ test('subsequent installs works', async ({ exec }) => { // of UnhandledPromiseRejection. await exec('node --unhandled-rejections=strict', path.join('node_modules', '@playwright', 'browser-chromium', 'install.js')); }); + +test('install playwright-chromium should work', async ({ exec, installedSoftwareOnDisk }) => { + await exec('npm i playwright-chromium'); + await exec('npx playwright install chromium'); + await exec('node sanity.js playwright-chromium chromium'); +}); From 52b803ecf5a2c9aff681a74b250cf11bf92677ef Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:39:18 -0600 Subject: [PATCH 022/141] feat(trace mode): add on-first-failure mode for traces (#29647) Implements the changes suggested in #29531 --- docs/src/test-api/class-testoptions.md | 5 +- packages/playwright/src/program.ts | 2 +- packages/playwright/src/worker/testTracing.ts | 30 +++++++- packages/playwright/types/test.d.ts | 6 +- tests/page/pageTestApi.ts | 2 +- .../playwright.artifacts.spec.ts | 25 ++++++ .../playwright-test/playwright.trace.spec.ts | 77 ++++++++++++++++++- utils/generate_types/overrides-test.d.ts | 5 +- 8 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 946171a935..0fba476d73 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -546,8 +546,8 @@ export default defineConfig({ ## property: TestOptions.trace * since: v1.10 -- type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry">> - - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries">> Trace recording mode. +- type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"retain-on-first-failure">> + - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries"|"retain-on-first-failure">> Trace recording mode. - `attachments` ?<[boolean]> Whether to include test attachments. Defaults to true. Optional. - `screenshots` ?<[boolean]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Defaults to true. Optional. - `snapshots` ?<[boolean]> Whether to capture DOM snapshot on every action. Defaults to true. Optional. @@ -559,6 +559,7 @@ Whether to record trace for each test. Defaults to `'off'`. * `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs. * `'on-first-retry'`: Record trace only when retrying a test for the first time. * `'on-all-retries'`: Record traces only when retrying for all retries. +* `'retain-on-first-failure'`: Record traces only when the test fails for the first time. For more control, pass an object that specifies `mode` and trace features to enable. diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index d24add96d3..fc67d5353e 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -290,7 +290,7 @@ function resolveReporter(id: string) { return require.resolve(id, { paths: [process.cwd()] }); } -const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure']; +const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure', 'retain-on-first-failure']; const testOptions: [string, string][] = [ ['--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`], diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 579bf8bc87..82c84000e7 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -48,8 +48,31 @@ export class TestTracing { this._tracesDir = path.join(this._artifactsDir, 'traces'); } + private _shouldCaptureTrace() { + if (process.env.PW_TEST_DISABLE_TRACING) + return false; + + if (this._options?.mode === 'on') + return true; + + if (this._options?.mode === 'retain-on-failure') + return true; + + if (this._options?.mode === 'on-first-retry' && this._testInfo.retry === 1) + return true; + + if (this._options?.mode === 'on-all-retries' && this._testInfo.retry > 0) + return true; + + if (this._options?.mode === 'retain-on-first-failure' && this._testInfo.retry === 0) + return true; + + return false; + } + async startIfNeeded(value: TraceFixtureValue) { const defaultTraceOptions: TraceOptions = { screenshots: true, snapshots: true, sources: true, attachments: true, _live: false, mode: 'off' }; + if (!value) { this._options = defaultTraceOptions; } else if (typeof value === 'string') { @@ -59,9 +82,7 @@ export class TestTracing { this._options = { ...defaultTraceOptions, ...value, mode: (mode as string) === 'retry-with-trace' ? 'on-first-retry' : mode }; } - let shouldCaptureTrace = this._options.mode === 'on' || this._options.mode === 'retain-on-failure' || (this._options.mode === 'on-first-retry' && this._testInfo.retry === 1) || (this._options.mode === 'on-all-retries' && this._testInfo.retry > 0); - shouldCaptureTrace = shouldCaptureTrace && !process.env.PW_TEST_DISABLE_TRACING; - if (!shouldCaptureTrace) { + if (!this._shouldCaptureTrace()) { this._options = undefined; return; } @@ -110,7 +131,8 @@ export class TestTracing { return; const testFailed = this._testInfo.status !== this._testInfo.expectedStatus; - const shouldAbandonTrace = !testFailed && this._options.mode === 'retain-on-failure'; + const shouldAbandonTrace = !testFailed && (this._options.mode === 'retain-on-failure' || this._options.mode === 'retain-on-first-failure'); + if (shouldAbandonTrace) { for (const file of this._temporaryTraceFiles) await fs.promises.unlink(file).catch(() => {}); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 7e329ae595..11ceb7153a 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5586,6 +5586,7 @@ export interface PlaywrightWorkerOptions { * - `'retain-on-failure'`: Record trace for each test, but remove all traces from successful test runs. * - `'on-first-retry'`: Record trace only when retrying a test for the first time. * - `'on-all-retries'`: Record traces only when retrying for all retries. + * - `'retain-on-first-failure'`: Record traces only when the test fails for the first time. * * For more control, pass an object that specifies `mode` and trace features to enable. * @@ -5636,7 +5637,7 @@ export interface PlaywrightWorkerOptions { } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; -export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries'; +export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; /** @@ -7099,7 +7100,8 @@ type MergedExpect = Expect>; export function mergeExpects(...expects: List): MergedExpect; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 -export {}; +export { }; + /** diff --git a/tests/page/pageTestApi.ts b/tests/page/pageTestApi.ts index 77340dfb2c..a7b124a84b 100644 --- a/tests/page/pageTestApi.ts +++ b/tests/page/pageTestApi.ts @@ -27,7 +27,7 @@ export type PageWorkerFixtures = { headless: boolean; channel: string; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; - trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; + trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'retain-on-first-failure' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; video: VideoMode | { mode: VideoMode, size: ViewportSize }; browserName: 'chromium' | 'firefox' | 'webkit'; browserVersion: string; diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 5a54d8f981..8a6db7fd2e 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -338,6 +338,31 @@ test('should work with trace: on-all-retries', async ({ runInlineTest }, testInf ]); }); +test('should work with trace: retain-on-first-failure', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + ...testFiles, + 'playwright.config.ts': ` + module.exports = { use: { trace: 'retain-on-first-failure' } }; + `, + }, { workers: 1, retries: 2 }); + + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(5); + expect(result.failed).toBe(5); + expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ + 'artifacts-failing', + ' trace.zip', + 'artifacts-own-context-failing', + ' trace.zip', + 'artifacts-persistent-failing', + ' trace.zip', + 'artifacts-shared-shared-failing', + ' trace.zip', + 'artifacts-two-contexts-failing', + ' trace.zip', + ]); +}); + test('should take screenshot when page is closed in afterEach', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ 'playwright.config.ts': ` diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 69f1912752..41ce802500 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -133,7 +133,6 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ]); }); - test('should not throw with trace: on-first-retry and two retries in the same worker', async ({ runInlineTest }, testInfo) => { const files = {}; for (let i = 0; i < 6; i++) { @@ -402,7 +401,7 @@ test('should respect PW_TEST_DISABLE_TRACING', async ({ runInlineTest }, testInf expect(fs.existsSync(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'))).toBe(false); }); -for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries']) { +for (const mode of ['off', 'retain-on-failure', 'on-first-retry', 'on-all-retries', 'retain-on-first-failure']) { test(`trace:${mode} should not create trace zip artifact if page test passed`, async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.spec.ts': ` @@ -1034,3 +1033,77 @@ test('should attribute worker fixture teardown to the right test', async ({ runI ' step in foo teardown', ]); }); + +test('trace:retain-on-first-failure should create trace but only on first failure', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page }) => { + await page.goto('about:blank'); + expect(true).toBe(false); + }); + `, + }, { trace: 'retain-on-first-failure', retries: 1 }); + + const retryTracePath = test.info().outputPath('test-results', 'a-fail-retry1', 'trace.zip'); + const retryTraceExists = fs.existsSync(retryTracePath); + expect(retryTraceExists).toBe(false); + + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if context is closed before failure in the test', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page, context }) => { + await page.goto('about:blank'); + await context.close(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if context is closed before failure in afterEach', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ page, context }) => { + }); + test.afterEach(async ({ page, context }) => { + await page.goto('about:blank'); + await context.close(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('page.goto'); + expect(result.failed).toBe(1); +}); + +test('trace:retain-on-first-failure should create trace if request context is disposed before failure', async ({ runInlineTest, server }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ request }) => { + expect(await request.get('${server.EMPTY_PAGE}')).toBeOK(); + await request.dispose(); + expect(1).toBe(2); + }); + `, + }, { trace: 'retain-on-first-failure' }); + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.apiNames).toContain('apiRequestContext.get'); + expect(result.failed).toBe(1); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index fd88e84dca..eed775d21f 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -248,7 +248,7 @@ export interface PlaywrightWorkerOptions { } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; -export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries'; +export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; export interface PlaywrightTestOptions { @@ -484,4 +484,5 @@ type MergedExpect = Expect>; export function mergeExpects(...expects: List): MergedExpect; // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 -export {}; +export { }; + From aedd7ca0bef98f1e14463bc4e8733300c9960a87 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 28 Feb 2024 15:51:27 -0800 Subject: [PATCH 023/141] chore: make CRNetworkManager handle multiple sessions (#29721) It was already handling worker sessions, but not OOPIFs. As a result, some functionality was properly implemented only for OOPIFs and not for workers. This change removes OOPIFs fanout for network-related calls from CRPage and moves that to the CRNetworkManager, now also covering workers. --- .../src/server/chromium/crBrowser.ts | 2 +- .../src/server/chromium/crNetworkManager.ts | 200 +++++++++++------- .../src/server/chromium/crPage.ts | 62 ++---- .../src/server/chromium/crServiceWorker.ts | 5 +- tests/page/workers.spec.ts | 29 +++ 5 files changed, 172 insertions(+), 126 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 7715d8b23f..9af7e6ba9d 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -562,7 +562,7 @@ export class CRBrowserContext extends BrowserContext { override async clearCache(): Promise { for (const page of this._crPages()) - await page._mainFrameSession._networkManager.clearCache(); + await page._networkManager.clearCache(); } async cancelDownload(guid: string) { diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index f3952b82fd..9e9eac0477 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -26,70 +26,88 @@ import type * as contexts from '../browserContext'; import type * as frames from '../frames'; import type * as types from '../types'; import type { CRPage } from './crPage'; -import { assert, headersObjectToArray } from '../../utils'; +import { assert, headersArrayToObject, headersObjectToArray } from '../../utils'; import type { CRServiceWorker } from './crServiceWorker'; -import { isProtocolError } from '../protocolError'; +import { isProtocolError, isSessionClosedError } from '../protocolError'; type SessionInfo = { session: CRSession; + isMain?: boolean; workerFrame?: frames.Frame; + eventListeners: RegisteredListener[]; }; export class CRNetworkManager { - private _session: CRSession; private _page: Page | null; private _serviceWorker: CRServiceWorker | null; - private _parentManager: CRNetworkManager | null; private _requestIdToRequest = new Map(); private _requestIdToRequestWillBeSentEvent = new Map(); private _credentials: {origin?: string, username: string, password: string} | null = null; private _attemptedAuthentications = new Set(); private _userRequestInterceptionEnabled = false; private _protocolRequestInterceptionEnabled = false; + private _offline = false; + private _extraHTTPHeaders: types.HeadersArray = []; private _requestIdToRequestPausedEvent = new Map(); - private _eventListeners: RegisteredListener[]; private _responseExtraInfoTracker = new ResponseExtraInfoTracker(); + private _sessions = new Map(); - constructor(session: CRSession, page: Page | null, serviceWorker: CRServiceWorker | null, parentManager: CRNetworkManager | null) { - this._session = session; + constructor(page: Page | null, serviceWorker: CRServiceWorker | null) { this._page = page; this._serviceWorker = serviceWorker; - this._parentManager = parentManager; - this._eventListeners = this.instrumentNetworkEvents({ session }); } - instrumentNetworkEvents(sessionInfo: SessionInfo): RegisteredListener[] { - const listeners = [ - eventsHelper.addEventListener(sessionInfo.session, 'Fetch.requestPaused', this._onRequestPaused.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Fetch.authRequired', this._onAuthRequired.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestWillBeSentExtraInfo', this._onRequestWillBeSentExtraInfo.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.responseReceived', this._onResponseReceived.bind(this, sessionInfo)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.responseReceivedExtraInfo', this._onResponseReceivedExtraInfo.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.loadingFailed', this._onLoadingFailed.bind(this, sessionInfo)), + async addSession(session: CRSession, workerFrame?: frames.Frame, isMain?: boolean) { + const sessionInfo: SessionInfo = { session, isMain, workerFrame, eventListeners: [] }; + sessionInfo.eventListeners = [ + eventsHelper.addEventListener(session, 'Fetch.requestPaused', this._onRequestPaused.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Fetch.authRequired', this._onAuthRequired.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.requestWillBeSentExtraInfo', this._onRequestWillBeSentExtraInfo.bind(this)), + eventsHelper.addEventListener(session, 'Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)), + eventsHelper.addEventListener(session, 'Network.responseReceived', this._onResponseReceived.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.responseReceivedExtraInfo', this._onResponseReceivedExtraInfo.bind(this)), + eventsHelper.addEventListener(session, 'Network.loadingFinished', this._onLoadingFinished.bind(this, sessionInfo)), + eventsHelper.addEventListener(session, 'Network.loadingFailed', this._onLoadingFailed.bind(this, sessionInfo)), ]; if (this._page) { - listeners.push(...[ - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketCreated', e => this._page!._frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!._frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketHandshakeResponseReceived', e => this._page!._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketClosed', e => this._page!._frameManager.webSocketClosed(e.requestId)), - eventsHelper.addEventListener(sessionInfo.session, 'Network.webSocketFrameError', e => this._page!._frameManager.webSocketError(e.requestId, e.errorMessage)), + sessionInfo.eventListeners.push(...[ + eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!._frameManager.onWebSocketCreated(e.requestId, e.url)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!._frameManager.onWebSocketRequest(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!._frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!._frameManager.webSocketError(e.requestId, e.errorMessage)), ]); } - return listeners; + this._sessions.set(session, sessionInfo); + await Promise.all([ + session.send('Network.enable'), + this._updateProtocolRequestInterceptionForSession(sessionInfo, true /* initial */), + this._setOfflineForSession(sessionInfo, true /* initial */), + this._setExtraHTTPHeadersForSession(sessionInfo, true /* initial */), + ]); } - async initialize() { - await this._session.send('Network.enable'); + removeSession(session: CRSession) { + const info = this._sessions.get(session); + if (info) + eventsHelper.removeEventListeners(info.eventListeners); + this._sessions.delete(session); } - dispose() { - eventsHelper.removeEventListeners(this._eventListeners); + private async _forEachSession(cb: (sessionInfo: SessionInfo) => Promise) { + await Promise.all([...this._sessions.values()].map(info => { + if (info.isMain) + return cb(info); + return cb(info).catch(e => { + // Broadcasting a message to the closed target should be a noop. + if (isSessionClosedError(e)) + return; + throw e; + }); + })); } async authenticate(credentials: types.Credentials | null) { @@ -98,8 +116,20 @@ export class CRNetworkManager { } async setOffline(offline: boolean) { - await this._session.send('Network.emulateNetworkConditions', { - offline, + if (offline === this._offline) + return; + this._offline = offline; + await this._forEachSession(info => this._setOfflineForSession(info)); + } + + private async _setOfflineForSession(info: SessionInfo, initial?: boolean) { + if (initial && !this._offline) + return; + // Workers are affected by the owner frame's Network.emulateNetworkConditions. + if (info.workerFrame) + return; + await info.session.send('Network.emulateNetworkConditions', { + offline: this._offline, // values of 0 remove any active throttling. crbug.com/456324#c9 latency: 0, downloadThroughput: -1, @@ -117,28 +147,46 @@ export class CRNetworkManager { if (enabled === this._protocolRequestInterceptionEnabled) return; this._protocolRequestInterceptionEnabled = enabled; - if (enabled) { - await Promise.all([ - this._session.send('Network.setCacheDisabled', { cacheDisabled: true }), - this._session.send('Fetch.enable', { - handleAuthRequests: true, - patterns: [{ urlPattern: '*', requestStage: 'Request' }], - }), - ]); - } else { - await Promise.all([ - this._session.send('Network.setCacheDisabled', { cacheDisabled: false }), - this._session.send('Fetch.disable') - ]); + await this._forEachSession(info => this._updateProtocolRequestInterceptionForSession(info)); + } + + private async _updateProtocolRequestInterceptionForSession(info: SessionInfo, initial?: boolean) { + const enabled = this._protocolRequestInterceptionEnabled; + if (initial && !enabled) + return; + const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled }); + let fetchPromise = Promise.resolve(undefined); + if (!info.workerFrame) { + if (enabled) + fetchPromise = info.session.send('Fetch.enable', { handleAuthRequests: true, patterns: [{ urlPattern: '*', requestStage: 'Request' }] }); + else + fetchPromise = info.session.send('Fetch.disable'); } + await Promise.all([cachePromise, fetchPromise]); + } + + async setExtraHTTPHeaders(extraHTTPHeaders: types.HeadersArray) { + if (!this._extraHTTPHeaders.length && !extraHTTPHeaders.length) + return; + this._extraHTTPHeaders = extraHTTPHeaders; + await this._forEachSession(info => this._setExtraHTTPHeadersForSession(info)); + } + + private async _setExtraHTTPHeadersForSession(info: SessionInfo, initial?: boolean) { + if (initial && !this._extraHTTPHeaders.length) + return; + await info.session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._extraHTTPHeaders, false /* lowerCase */) }); } async clearCache() { - // Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache. - await this._session.send('Network.setCacheDisabled', { cacheDisabled: true }); - if (!this._protocolRequestInterceptionEnabled) - await this._session.send('Network.setCacheDisabled', { cacheDisabled: false }); - await this._session.send('Network.clearBrowserCache'); + await this._forEachSession(async info => { + // Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache. + await info.session.send('Network.setCacheDisabled', { cacheDisabled: true }); + if (!this._protocolRequestInterceptionEnabled) + await info.session.send('Network.setCacheDisabled', { cacheDisabled: false }); + if (!info.workerFrame) + await info.session.send('Network.clearBrowserCache'); + }); } _onRequestWillBeSent(sessionInfo: SessionInfo, event: Protocol.Network.requestWillBeSentPayload) { @@ -165,7 +213,7 @@ export class CRNetworkManager { this._responseExtraInfoTracker.requestWillBeSentExtraInfo(event); } - _onAuthRequired(event: Protocol.Fetch.authRequiredPayload) { + _onAuthRequired(sessionInfo: SessionInfo, event: Protocol.Fetch.authRequiredPayload) { let response: 'Default' | 'CancelAuth' | 'ProvideCredentials' = 'Default'; const shouldProvideCredentials = this._shouldProvideCredentials(event.request.url); if (this._attemptedAuthentications.has(event.requestId)) { @@ -175,7 +223,7 @@ export class CRNetworkManager { this._attemptedAuthentications.add(event.requestId); } const { username, password } = shouldProvideCredentials && this._credentials ? this._credentials : { username: undefined, password: undefined }; - this._session._sendMayFail('Fetch.continueWithAuth', { + sessionInfo.session._sendMayFail('Fetch.continueWithAuth', { requestId: event.requestId, authChallengeResponse: { response, username, password }, }); @@ -191,7 +239,7 @@ export class CRNetworkManager { if (!event.networkId) { // Fetch without networkId means that request was not recognized by inspector, and // it will never receive Network.requestWillBeSent. Continue the request to not affect it. - this._session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); return; } if (event.request.url.startsWith('data:')) @@ -215,7 +263,7 @@ export class CRNetworkManager { // // Note: make sure not to prematurely continue the redirect, which shares the // `networkId` between the original request and the redirect. - this._session._sendMayFail('Fetch.continueRequest', { + sessionInfo.session._sendMayFail('Fetch.continueRequest', { ...alreadyContinuedParams, requestId: event.requestId, }); @@ -266,7 +314,7 @@ export class CRNetworkManager { ]; if (requestHeaders['Access-Control-Request-Headers']) responseHeaders.push({ name: 'Access-Control-Allow-Headers', value: requestHeaders['Access-Control-Request-Headers'] }); - this._session._sendMayFail('Fetch.fulfillRequest', { + sessionInfo.session._sendMayFail('Fetch.fulfillRequest', { requestId: requestPausedEvent.requestId, responseCode: 204, responsePhrase: network.STATUS_TEXTS['204'], @@ -279,7 +327,7 @@ export class CRNetworkManager { // Non-service-worker requests MUST have a frame—if they don't, we pretend there was no request if (!frame && !this._serviceWorker) { if (requestPausedEvent) - this._session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); return; } @@ -289,9 +337,9 @@ export class CRNetworkManager { if (redirectedFrom || (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled)) { // Chromium does not preserve header overrides between redirects, so we have to do it ourselves. const headers = redirectedFrom?._originalRequestRoute?._alreadyContinuedParams?.headers; - this._session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId, headers }); + sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId, headers }); } else { - route = new RouteImpl(this._session, requestPausedEvent.requestId); + route = new RouteImpl(sessionInfo.session, requestPausedEvent.requestId); } } const isNavigationRequest = requestWillBeSentEvent.requestId === requestWillBeSentEvent.loaderId && requestWillBeSentEvent.type === 'Document'; @@ -426,16 +474,15 @@ export class CRNetworkManager { (this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response); } - _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) { + _onLoadingFinished(sessionInfo: SessionInfo, event: Protocol.Network.loadingFinishedPayload) { this._responseExtraInfoTracker.loadingFinished(event); - let request = this._requestIdToRequest.get(event.requestId); - if (!request) - request = this._maybeAdoptMainRequest(event.requestId); + const request = this._requestIdToRequest.get(event.requestId); // For certain requestIds we never receive requestWillBeSent event. // @see https://crbug.com/750469 if (!request) return; + this._maybeUpdateOOPIFMainRequest(sessionInfo, request); // Under certain conditions we never get the Network.responseReceived // event from protocol. @see https://crbug.com/883475 @@ -453,8 +500,6 @@ export class CRNetworkManager { this._responseExtraInfoTracker.loadingFailed(event); let request = this._requestIdToRequest.get(event.requestId); - if (!request) - request = this._maybeAdoptMainRequest(event.requestId); if (!request) { const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(event.requestId); @@ -472,6 +517,7 @@ export class CRNetworkManager { // @see https://crbug.com/750469 if (!request) return; + this._maybeUpdateOOPIFMainRequest(sessionInfo, request); const response = request.request._existingResponse(); if (response) { response.setTransferSize(null); @@ -483,22 +529,12 @@ export class CRNetworkManager { (this._page?._frameManager || this._serviceWorker)!.requestFailed(request.request, !!event.canceled); } - private _maybeAdoptMainRequest(requestId: Protocol.Network.RequestId): InterceptableRequest | undefined { + private _maybeUpdateOOPIFMainRequest(sessionInfo: SessionInfo, request: InterceptableRequest) { // OOPIF has a main request that starts in the parent session but finishes in the child session. - if (!this._parentManager) - return; - const request = this._parentManager._requestIdToRequest.get(requestId); - // Main requests have matching loaderId and requestId. - if (!request || request._documentId !== requestId) - return; - this._requestIdToRequest.set(requestId, request); - request.session = this._session; - this._parentManager._requestIdToRequest.delete(requestId); - if (request._interceptionId && this._parentManager._attemptedAuthentications.has(request._interceptionId)) { - this._parentManager._attemptedAuthentications.delete(request._interceptionId); - this._attemptedAuthentications.add(request._interceptionId); - } - return request; + // We check for the main request by matching loaderId and requestId, and if it now belongs to + // a child session, migrate it there. + if (request.session !== sessionInfo.session && !sessionInfo.isMain && request._documentId === request._requestId) + request.session = sessionInfo.session; } } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 2c0d3f94b4..6de00fd80d 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -20,7 +20,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import { registry } from '../registry'; import { rewriteErrorMessage } from '../../utils/stackTrace'; -import { assert, createGuid, headersArrayToObject } from '../../utils'; +import { assert, createGuid } from '../../utils'; import * as dialog from '../dialog'; import * as dom from '../dom'; import * as frames from '../frames'; @@ -61,6 +61,7 @@ export class CRPage implements PageDelegate { readonly rawTouchscreen: RawTouchscreenImpl; readonly _targetId: string; readonly _opener: CRPage | null; + readonly _networkManager: CRNetworkManager; private readonly _pdf: CRPDF; private readonly _coverage: CRCoverage; readonly _browserContext: CRBrowserContext; @@ -92,6 +93,13 @@ export class CRPage implements PageDelegate { this._coverage = new CRCoverage(client); this._browserContext = browserContext; this._page = new Page(this, browserContext); + this._networkManager = new CRNetworkManager(this._page, null); + // Sync any browser context state to the network manager. This does not talk over CDP because + // we have not connected any sessions to the network manager yet. + this.updateOffline(); + this.updateExtraHTTPHeaders(); + this.updateHttpCredentials(); + this.updateRequestInterception(); this._mainFrameSession = new FrameSession(this, client, targetId, null); this._sessions.set(targetId, this._mainFrameSession); if (opener && !browserContext._options.noDefaultViewport) { @@ -184,7 +192,11 @@ export class CRPage implements PageDelegate { } async updateExtraHTTPHeaders(): Promise { - await this._forAllFrameSessions(frame => frame._updateExtraHTTPHeaders(false)); + const headers = network.mergeHeaders([ + this._browserContext._options.extraHTTPHeaders, + this._page.extraHTTPHeaders() + ]); + await this._networkManager.setExtraHTTPHeaders(headers); } async updateGeolocation(): Promise { @@ -192,11 +204,11 @@ export class CRPage implements PageDelegate { } async updateOffline(): Promise { - await this._forAllFrameSessions(frame => frame._updateOffline(false)); + await this._networkManager.setOffline(!!this._browserContext._options.offline); } async updateHttpCredentials(): Promise { - await this._forAllFrameSessions(frame => frame._updateHttpCredentials(false)); + await this._networkManager.authenticate(this._browserContext._options.httpCredentials || null); } async updateEmulatedViewportSize(preserveWindowBoundaries?: boolean): Promise { @@ -216,7 +228,7 @@ export class CRPage implements PageDelegate { } async updateRequestInterception(): Promise { - await this._forAllFrameSessions(frame => frame._updateRequestInterception()); + await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); } async updateFileChooserInterception() { @@ -392,7 +404,6 @@ class FrameSession { readonly _client: CRSession; readonly _crPage: CRPage; readonly _page: Page; - readonly _networkManager: CRNetworkManager; private readonly _parentSession: FrameSession | null; private readonly _childSessions = new Set(); private readonly _contextIdToContext = new Map(); @@ -418,7 +429,6 @@ class FrameSession { this._crPage = crPage; this._page = crPage._page; this._targetId = targetId; - this._networkManager = new CRNetworkManager(client, this._page, null, parentSession ? parentSession._networkManager : null); this._parentSession = parentSession; if (parentSession) parentSession._childSessions.add(this); @@ -533,7 +543,7 @@ class FrameSession { source: '', worldName: UTILITY_WORLD_NAME, }), - this._networkManager.initialize(), + this._crPage._networkManager.addSession(this._client, undefined, this._isMainFrame()), this._client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }), ]; if (!isSettingStorageState) { @@ -559,10 +569,6 @@ class FrameSession { if (!this._crPage._browserContext._browser.options.headful) promises.push(this._setDefaultFontFamilies(this._client)); promises.push(this._updateGeolocation(true)); - promises.push(this._updateExtraHTTPHeaders(true)); - promises.push(this._updateRequestInterception()); - promises.push(this._updateOffline(true)); - promises.push(this._updateHttpCredentials(true)); promises.push(this._updateEmulateMedia()); promises.push(this._updateFileChooserInterception(true)); for (const binding of this._crPage._page.allBindings()) @@ -586,7 +592,7 @@ class FrameSession { if (this._parentSession) this._parentSession._childSessions.delete(this); eventsHelper.removeEventListeners(this._eventListeners); - this._networkManager.dispose(); + this._crPage._networkManager.removeSession(this._client); this._crPage._sessions.delete(this._targetId); this._client.dispose(); } @@ -752,7 +758,8 @@ class FrameSession { }); // This might fail if the target is closed before we initialize. session._sendMayFail('Runtime.enable'); - session._sendMayFail('Network.enable'); + // TODO: attribute workers to the right frame. + this._crPage._networkManager.addSession(session, this._page._frameManager.frame(this._targetId) ?? undefined).catch(() => {}); session._sendMayFail('Runtime.runIfWaitingForDebugger'); session._sendMayFail('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }); session.on('Target.attachedToTarget', event => this._onAttachedToTarget(event)); @@ -762,8 +769,6 @@ class FrameSession { this._page._addConsoleMessage(event.type, args, toConsoleMessageLocation(event.stackTrace)); }); session.on('Runtime.exceptionThrown', exception => this._page.emitOnContextOnceInitialized(BrowserContext.Events.PageError, exceptionToError(exception.exceptionDetails), this._page)); - // TODO: attribute workers to the right frame. - this._networkManager.instrumentNetworkEvents({ session, workerFrame: this._page._frameManager.frame(this._targetId) ?? undefined }); } _onDetachedFromTarget(event: Protocol.Target.detachedFromTargetPayload) { @@ -981,33 +986,12 @@ class FrameSession { await this._client._sendMayFail('Page.stopScreencast'); } - async _updateExtraHTTPHeaders(initial: boolean): Promise { - const headers = network.mergeHeaders([ - this._crPage._browserContext._options.extraHTTPHeaders, - this._page.extraHTTPHeaders() - ]); - if (!initial || headers.length) - await this._client.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(headers, false /* lowerCase */) }); - } - async _updateGeolocation(initial: boolean): Promise { const geolocation = this._crPage._browserContext._options.geolocation; if (!initial || geolocation) await this._client.send('Emulation.setGeolocationOverride', geolocation || {}); } - async _updateOffline(initial: boolean): Promise { - const offline = !!this._crPage._browserContext._options.offline; - if (!initial || offline) - await this._networkManager.setOffline(offline); - } - - async _updateHttpCredentials(initial: boolean): Promise { - const credentials = this._crPage._browserContext._options.httpCredentials || null; - if (!initial || credentials) - await this._networkManager.authenticate(credentials); - } - async _updateViewport(preserveWindowBoundaries?: boolean): Promise { if (this._crPage._browserContext._browser.isClank()) return; @@ -1106,10 +1090,6 @@ class FrameSession { await session.send('Page.setFontFamilies', fontFamilies); } - async _updateRequestInterception(): Promise { - await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); - } - async _updateFileChooserInterception(initial: boolean) { const enabled = this._page.fileChooserIntercepted(); if (initial && !enabled) diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index 11d6385356..4de63fac55 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -34,13 +34,13 @@ export class CRServiceWorker extends Worker { this._session = session; this._browserContext = browserContext; if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS) - this._networkManager = new CRNetworkManager(session, null, this, null); + this._networkManager = new CRNetworkManager(null, this); session.once('Runtime.executionContextCreated', event => { this._createExecutionContext(new CRExecutionContext(session, event.context)); }); if (this._networkManager && this._isNetworkInspectionEnabled()) { - this._networkManager.initialize().catch(() => {}); + this._networkManager.addSession(session, undefined, true /* isMain */).catch(() => {}); this.updateRequestInterception(); this.updateExtraHTTPHeaders(true); this.updateHttpCredentials(true); @@ -56,6 +56,7 @@ export class CRServiceWorker extends Worker { } override didClose() { + this._networkManager?.removeSession(this._session); this._session.dispose(); super.didClose(); } diff --git a/tests/page/workers.spec.ts b/tests/page/workers.spec.ts index 9ad0e32238..3b4d1bf5c8 100644 --- a/tests/page/workers.spec.ts +++ b/tests/page/workers.spec.ts @@ -224,3 +224,32 @@ it('should report and intercept network from nested worker', async function({ pa await expect.poll(() => messages).toEqual(['{"foo":"not bar"}', '{"foo":"not bar"}']); }); + +it('should support extra http headers', async ({ page, server }) => { + await page.setExtraHTTPHeaders({ foo: 'bar' }); + const [worker, request1] = await Promise.all([ + page.waitForEvent('worker'), + server.waitForRequest('/worker/worker.js'), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + const [request2] = await Promise.all([ + server.waitForRequest('/one-style.css'), + worker.evaluate(url => fetch(url), server.PREFIX + '/one-style.css'), + ]); + expect(request1.headers['foo']).toBe('bar'); + expect(request2.headers['foo']).toBe('bar'); +}); + +it('should support offline', async ({ page, server, browserName }) => { + it.fixme(browserName === 'firefox'); + + const [worker] = await Promise.all([ + page.waitForEvent('worker'), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + await page.context().setOffline(true); + expect(await worker.evaluate(() => navigator.onLine)).toBe(false); + expect(await worker.evaluate(() => fetch('/one-style.css').catch(e => 'error'))).toBe('error'); + await page.context().setOffline(false); + expect(await worker.evaluate(() => navigator.onLine)).toBe(true); +}); From f4f26fdf7b5de707da1adbfe0387dad90d081b03 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Thu, 29 Feb 2024 10:25:54 +0100 Subject: [PATCH 024/141] docs(reporters): added 3rd-party GitHub Actions Reporter (#29728) --- docs/src/test-reporters-js.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index 2673739c9c..ecece58dcd 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -354,6 +354,7 @@ npx playwright test --reporter="./myreporter/my-awesome-reporter.ts" * [Allure](https://www.npmjs.com/package/allure-playwright) * [Argos Visual Testing](https://argos-ci.com/docs/playwright) * [Currents](https://www.npmjs.com/package/@currents/playwright) +* [GitHub Actions Reporter](https://www.npmjs.com/package/@estruyf/github-actions-reporter) * [Monocart](https://github.com/cenfun/monocart-reporter) * [ReportPortal](https://github.com/reportportal/agent-js-playwright) * [Serenity/JS](https://serenity-js.org/handbook/test-runners/playwright-test) From 6bd7665bcef3e49f56826e95dd550a37f66b480c Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 29 Feb 2024 06:37:32 -0800 Subject: [PATCH 025/141] feat(webkit): roll to r1984 (#29736) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index bf862ae74c..f5dd6b43db 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "1983", + "revision": "1984", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From d9a00342c84be6618b07315911e74cac22efda64 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 29 Feb 2024 09:02:05 -0800 Subject: [PATCH 026/141] fix(HEAD): revert GET->HEAD migration, net-effect was negative (#29738) Fixes: https://github.com/microsoft/playwright/issues/29732 --- packages/playwright-core/src/utils/network.ts | 3 +-- tests/playwright-test/web-server.spec.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/utils/network.ts b/packages/playwright-core/src/utils/network.ts index bf834abaa0..8cacb8aeb6 100644 --- a/packages/playwright-core/src/utils/network.ts +++ b/packages/playwright-core/src/utils/network.ts @@ -181,9 +181,8 @@ export async function isURLAvailable(url: URL, ignoreHTTPSErrors: boolean, onLog async function httpStatusCode(url: URL, ignoreHTTPSErrors: boolean, onLog?: (data: string) => void, onStdErr?: (data: string) => void): Promise { return new Promise(resolve => { - onLog?.(`HTTP HEAD: ${url}`); + onLog?.(`HTTP GET: ${url}`); httpRequest({ - method: 'HEAD', url: url.toString(), headers: { Accept: '*/*' }, rejectUnauthorized: !ignoreHTTPSErrors diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index d2eacb2d55..04e3c1d328 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -662,7 +662,7 @@ test('should check ipv4 and ipv6 with happy eyeballs when URL is passed', async expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); expect(result.output).toContain('Process started'); - expect(result.output).toContain(`HTTP HEAD: http://localhost:${port}/`); + expect(result.output).toContain(`HTTP GET: http://localhost:${port}/`); expect(result.output).toContain('WebServer available'); }); From 30be5e0d26256c95c63ea9ba37c7f1ed4213ee46 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 29 Feb 2024 09:58:37 -0800 Subject: [PATCH 027/141] feat(webkit): roll to r1985 (#29740) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index f5dd6b43db..528633fddb 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "1984", + "revision": "1985", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 0da29959fa1274b352f34b19f22a8a3cfdd70bbe Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:10:46 -0800 Subject: [PATCH 028/141] feat(chromium-tip-of-tree): roll to r1196 (#29742) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 528633fddb..dcb653a1c4 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,9 +15,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1195", + "revision": "1196", "installByDefault": false, - "browserVersion": "123.0.6312.0" + "browserVersion": "124.0.6315.0" }, { "name": "firefox", From 99744d06834b16cce53d3e7fb0437c71ff54c14e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 29 Feb 2024 12:31:07 -0800 Subject: [PATCH 029/141] chore: normalize telereceiver type imports (#29745) --- .../playwright/src/isomorphic/teleReceiver.ts | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index a698f2b119..ce6834844e 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { FullConfig, FullResult, Location, TestError, TestResult, TestStatus, TestStep } from '../../types/testReporter'; import type { Annotation } from '../common/config'; import type { FullProject, Metadata } from '../../types/test'; import type * as reporterTypes from '../../types/testReporter'; @@ -22,17 +21,17 @@ import type { SuitePrivate } from '../../types/reporterPrivate'; import type { ReporterV2 } from '../reporters/reporterV2'; import { StringInternPool } from './stringInternPool'; -export type JsonLocation = Location; +export type JsonLocation = reporterTypes.Location; export type JsonError = string; export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonStdIOType = 'stdout' | 'stderr'; -export type JsonConfig = Pick & { +export type JsonConfig = Pick & { listOnly: boolean; }; -export type MergeReporterConfig = Pick; +export type MergeReporterConfig = Pick; export type JsonPattern = { s?: string; @@ -78,7 +77,7 @@ export type JsonTestCase = { export type JsonTestEnd = { testId: string; - expectedStatus: TestStatus; + expectedStatus: reporterTypes.TestStatus; timeout: number; annotations: { type: string, description?: string }[]; }; @@ -91,13 +90,13 @@ export type JsonTestResultStart = { startTime: number; }; -export type JsonAttachment = Omit & { base64?: string }; +export type JsonAttachment = Omit & { base64?: string }; export type JsonTestResultEnd = { id: string; duration: number; - status: TestStatus; - errors: TestError[]; + status: reporterTypes.TestStatus; + errors: reporterTypes.TestError[]; attachments: JsonAttachment[]; }; @@ -107,17 +106,17 @@ export type JsonTestStepStart = { title: string; category: string, startTime: number; - location?: Location; + location?: reporterTypes.Location; }; export type JsonTestStepEnd = { id: string; duration: number; - error?: TestError; + error?: reporterTypes.TestError; }; export type JsonFullResult = { - status: FullResult['status']; + status: reporterTypes.FullResult['status']; startTime: number; duration: number; }; @@ -137,7 +136,7 @@ export class TeleReporterReceiver { private _clearPreviousResultsWhenTestBegins: boolean = false; private _reuseTestCases: boolean; private _reportConfig: MergeReporterConfig | undefined; - private _config!: FullConfig; + private _config!: reporterTypes.FullConfig; private _stringPool = new StringInternPool(); constructor(pathSeparator: string, reporter: Partial, reuseTestCases: boolean, reportConfig?: MergeReporterConfig) { @@ -290,7 +289,7 @@ export class TeleReporterReceiver { this._reporter.onStepEnd?.(test, result, step); } - private _onError(error: TestError) { + private _onError(error: reporterTypes.TestError) { this._reporter.onError?.(error); } @@ -321,7 +320,7 @@ export class TeleReporterReceiver { return this._reporter.onExit?.(); } - private _parseConfig(config: JsonConfig): FullConfig { + private _parseConfig(config: JsonConfig): reporterTypes.FullConfig { const result = { ...baseFullConfig, ...config }; if (this._reportConfig) { result.configFile = this._reportConfig.configFile; @@ -353,7 +352,7 @@ export class TeleReporterReceiver { }; } - private _parseAttachments(attachments: JsonAttachment[]): TestResult['attachments'] { + private _parseAttachments(attachments: JsonAttachment[]): reporterTypes.TestResult['attachments'] { return attachments.map(a => { return { ...a, @@ -399,9 +398,9 @@ export class TeleReporterReceiver { return test; } - private _absoluteLocation(location: Location): Location; - private _absoluteLocation(location?: Location): Location | undefined; - private _absoluteLocation(location: Location | undefined): Location | undefined { + private _absoluteLocation(location: reporterTypes.Location): reporterTypes.Location; + private _absoluteLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; + private _absoluteLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { if (!location) return location; return { @@ -422,7 +421,7 @@ export class TeleReporterReceiver { export class TeleSuite implements SuitePrivate { title: string; - location?: Location; + location?: reporterTypes.Location; parent?: TeleSuite; _requireFile: string = ''; suites: TeleSuite[] = []; @@ -470,7 +469,7 @@ export class TeleTestCase implements reporterTypes.TestCase { title: string; fn = () => {}; results: TeleTestResult[] = []; - location: Location; + location: reporterTypes.Location; parent!: TeleSuite; expectedStatus: reporterTypes.TestStatus = 'passed'; @@ -483,7 +482,7 @@ export class TeleTestCase implements reporterTypes.TestCase { resultsMap = new Map(); - constructor(id: string, title: string, location: Location) { + constructor(id: string, title: string, location: reporterTypes.Location) { this.id = id; this.title = title; this.location = location; @@ -531,17 +530,17 @@ export class TeleTestCase implements reporterTypes.TestCase { } } -class TeleTestStep implements TestStep { +class TeleTestStep implements reporterTypes.TestStep { title: string; category: string; - location: Location | undefined; - parent: TestStep | undefined; + location: reporterTypes.Location | undefined; + parent: reporterTypes.TestStep | undefined; duration: number = -1; - steps: TestStep[] = []; + steps: reporterTypes.TestStep[] = []; private _startTime: number = 0; - constructor(payload: JsonTestStepStart, parentStep: TestStep | undefined, location: Location | undefined) { + constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) { this.title = payload.title; this.category = payload.category; this.location = location; @@ -571,7 +570,7 @@ class TeleTestResult implements reporterTypes.TestResult { stdout: reporterTypes.TestResult['stdout'] = []; stderr: reporterTypes.TestResult['stderr'] = []; attachments: reporterTypes.TestResult['attachments'] = []; - status: TestStatus = 'skipped'; + status: reporterTypes.TestStatus = 'skipped'; steps: TeleTestStep[] = []; errors: reporterTypes.TestResult['errors'] = []; error: reporterTypes.TestResult['error']; @@ -600,7 +599,7 @@ class TeleTestResult implements reporterTypes.TestResult { export type TeleFullProject = FullProject & { __projectId: string }; -export const baseFullConfig: FullConfig = { +export const baseFullConfig: reporterTypes.FullConfig = { forbidOnly: false, fullyParallel: false, globalSetup: null, From 532d8e550066d66921ccbd0ba67767beba21b28a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 29 Feb 2024 12:48:15 -0800 Subject: [PATCH 030/141] fix(ct): fix the non-react cli entry points (#29748) Fixes https://github.com/microsoft/playwright/issues/29746 --- packages/playwright-ct-react17/cli.js | 4 +--- packages/playwright-ct-solid/cli.js | 4 +--- packages/playwright-ct-svelte/cli.js | 4 +--- packages/playwright-ct-vue/cli.js | 4 +--- packages/playwright-ct-vue2/cli.js | 4 +--- 5 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/playwright-ct-react17/cli.js b/packages/playwright-ct-react17/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-react17/cli.js +++ b/packages/playwright-ct-react17/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-solid/cli.js b/packages/playwright-ct-solid/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-solid/cli.js +++ b/packages/playwright-ct-solid/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-svelte/cli.js b/packages/playwright-ct-svelte/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-svelte/cli.js +++ b/packages/playwright-ct-svelte/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-vue/cli.js b/packages/playwright-ct-vue/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-vue/cli.js +++ b/packages/playwright-ct-vue/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); diff --git a/packages/playwright-ct-vue2/cli.js b/packages/playwright-ct-vue2/cli.js index b6f935eb52..9cc834ee95 100755 --- a/packages/playwright-ct-vue2/cli.js +++ b/packages/playwright-ct-vue2/cli.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const { program, initializePlugin } = require('@playwright/experimental-ct-core/lib/program'); -const { _framework } = require('./index'); +const { program } = require('@playwright/experimental-ct-core/lib/program'); -initializePlugin(_framework); program.parse(process.argv); From 0f30cdab236366f58230f434b63c214343b1aa0f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 29 Feb 2024 14:44:45 -0800 Subject: [PATCH 031/141] feat(firefox): roll ff and ff-beta to 1442 (#29751) This requires changes in `FFPage`. Also fixing a new ff-specific test that introduced flakiness on the bots. Closes https://github.com/microsoft/playwright/pull/29750 Closes https://github.com/microsoft/playwright/pull/29724 Closes https://github.com/microsoft/playwright/pull/29681 Closes https://github.com/microsoft/playwright/pull/29678 --- packages/playwright-core/browsers.json | 6 +++--- packages/playwright-core/src/server/chromium/crPage.ts | 2 +- packages/playwright-core/src/server/dom.ts | 2 +- packages/playwright-core/src/server/firefox/ffPage.ts | 4 +--- packages/playwright-core/src/server/page.ts | 2 +- packages/playwright-core/src/server/webkit/wkPage.ts | 2 +- tests/library/browsercontext-basic.spec.ts | 3 +++ 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index dcb653a1c4..f936b9c657 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -21,19 +21,19 @@ }, { "name": "firefox", - "revision": "1440", + "revision": "1442", "installByDefault": true, "browserVersion": "123.0" }, { "name": "firefox-asan", - "revision": "1440", + "revision": "1442", "installByDefault": false, "browserVersion": "123.0" }, { "name": "firefox-beta", - "revision": "1440", + "revision": "1442", "installByDefault": false, "browserVersion": "124.0b3" }, diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 6de00fd80d..2828991e11 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -345,7 +345,7 @@ export class CRPage implements PageDelegate { injected.setInputFiles(node, files), files); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { const frame = await handle.ownerFrame(); if (!frame) throw new Error('Cannot set input files to detached input element'); diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ef7b152b88..f2be757de4 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -643,7 +643,7 @@ export class ElementHandle extends js.JSHandle { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { progress.throwIfAborted(); // Avoid action that has side-effects. if (localPaths) - await this._page._delegate.setInputFilePaths(progress, retargeted, localPaths); + await this._page._delegate.setInputFilePaths(retargeted, localPaths); else await this._page._delegate.setInputFiles(retargeted, filePayloads!); }); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index a93551b8cd..63be31ba29 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -543,14 +543,12 @@ export class FFPage implements PageDelegate { injected.setInputFiles(node, files), files); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise { + async setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise { await this._session.send('Page.setFileInputFiles', { frameId: handle._context.frame._id, objectId: handle._objectId, files }); - await handle.dispatchEvent(progress.metadata, 'input'); - await handle.dispatchEvent(progress.metadata, 'change'); } async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 359fed8360..3a666fa6a6 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -80,7 +80,7 @@ export interface PageDelegate { getOwnerFrame(handle: dom.ElementHandle): Promise; // Returns frameId. getContentQuads(handle: dom.ElementHandle): Promise; setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise; - setInputFilePaths(progress: Progress, handle: dom.ElementHandle, files: string[]): Promise; + setInputFilePaths(handle: dom.ElementHandle, files: string[]): Promise; getBoundingBox(handle: dom.ElementHandle): Promise; getFrameElement(frame: frames.Frame): Promise; scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>; diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 038010f456..e0326072f7 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -966,7 +966,7 @@ export class WKPage implements PageDelegate { await this._session.send('DOM.setInputFiles', { objectId, files: protocolFiles }); } - async setInputFilePaths(progress: Progress, handle: dom.ElementHandle, paths: string[]): Promise { + async setInputFilePaths(handle: dom.ElementHandle, paths: string[]): Promise { const pageProxyId = this._pageProxySession.sessionId; const objectId = handle._objectId; await Promise.all([ diff --git a/tests/library/browsercontext-basic.spec.ts b/tests/library/browsercontext-basic.spec.ts index 266eb80e1c..f075184cf9 100644 --- a/tests/library/browsercontext-basic.spec.ts +++ b/tests/library/browsercontext-basic.spec.ts @@ -62,6 +62,9 @@ it('should be able to click across browser contexts', async function({ browser } ]); expect(await getClicks(page1)).toBe(CLICK_COUNT); expect(await getClicks(page2)).toBe(CLICK_COUNT); + + await page1.close(); + await page2.close(); }); it('window.open should use parent tab context', async function({ browser, server }) { From 989cf8f179dd3097b5b6cdb5a69153516812c578 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 29 Feb 2024 16:50:00 -0800 Subject: [PATCH 032/141] chore: remove pw- binaries from ct (#29754) --- package-lock.json | 18 ++++++------------ packages/playwright-ct-react/package.json | 3 +-- packages/playwright-ct-react17/package.json | 3 +-- packages/playwright-ct-solid/package.json | 3 +-- packages/playwright-ct-svelte/package.json | 3 +-- packages/playwright-ct-vue/package.json | 3 +-- packages/playwright-ct-vue2/package.json | 3 +-- 7 files changed, 12 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7159768fe7..2fc437eade 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8236,8 +8236,7 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8252,8 +8251,7 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react17": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8268,8 +8266,7 @@ "vite-plugin-solid": "^2.7.0" }, "bin": { - "playwright": "cli.js", - "pw-solid": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "solid-js": "^1.7.0" @@ -8287,8 +8284,7 @@ "@sveltejs/vite-plugin-svelte": "^3.0.1" }, "bin": { - "playwright": "cli.js", - "pw-svelte": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "svelte": "^4.2.8" @@ -8306,8 +8302,7 @@ "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-vue": "cli.js" + "playwright": "cli.js" }, "engines": { "node": ">=16" @@ -8322,8 +8317,7 @@ "@vitejs/plugin-vue2": "^2.2.0" }, "bin": { - "playwright": "cli.js", - "pw-vue2": "cli.js" + "playwright": "cli.js" }, "devDependencies": { "vue": "^2.7.14" diff --git a/packages/playwright-ct-react/package.json b/packages/playwright-ct-react/package.json index cc42049882..30b8f15871 100644 --- a/packages/playwright-ct-react/package.json +++ b/packages/playwright-ct-react/package.json @@ -33,7 +33,6 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-react17/package.json b/packages/playwright-ct-react17/package.json index 78f43509e8..1ac183e407 100644 --- a/packages/playwright-ct-react17/package.json +++ b/packages/playwright-ct-react17/package.json @@ -33,7 +33,6 @@ "@vitejs/plugin-react": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-react17": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-solid/package.json b/packages/playwright-ct-solid/package.json index 84e0355fb2..54833eeba3 100644 --- a/packages/playwright-ct-solid/package.json +++ b/packages/playwright-ct-solid/package.json @@ -36,7 +36,6 @@ "solid-js": "^1.7.0" }, "bin": { - "playwright": "cli.js", - "pw-solid": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-svelte/package.json b/packages/playwright-ct-svelte/package.json index 5b9ff27347..4aeaa91a11 100644 --- a/packages/playwright-ct-svelte/package.json +++ b/packages/playwright-ct-svelte/package.json @@ -36,7 +36,6 @@ "svelte": "^4.2.8" }, "bin": { - "playwright": "cli.js", - "pw-svelte": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-vue/package.json b/packages/playwright-ct-vue/package.json index c628a490d8..d125aad45d 100644 --- a/packages/playwright-ct-vue/package.json +++ b/packages/playwright-ct-vue/package.json @@ -33,7 +33,6 @@ "@vitejs/plugin-vue": "^4.2.1" }, "bin": { - "playwright": "cli.js", - "pw-vue": "cli.js" + "playwright": "cli.js" } } diff --git a/packages/playwright-ct-vue2/package.json b/packages/playwright-ct-vue2/package.json index c19820ae91..aed18496f7 100644 --- a/packages/playwright-ct-vue2/package.json +++ b/packages/playwright-ct-vue2/package.json @@ -36,7 +36,6 @@ "vue": "^2.7.14" }, "bin": { - "playwright": "cli.js", - "pw-vue2": "cli.js" + "playwright": "cli.js" } } From c08a4e72d154619e2ce7a790bb35534a975099d2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 29 Feb 2024 16:59:39 -0800 Subject: [PATCH 033/141] chore: call testInfo.snapshotPath directly (#29755) Reference https://github.com/microsoft/playwright/issues/29719 --- .../playwright/src/matchers/toMatchSnapshot.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 80e602f5ad..449c7349f0 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -22,7 +22,7 @@ import { getComparator, sanitizeForFilePath, zones } from 'playwright-core/lib/u import { addSuffixToFilePath, trimLongString, callLogText, - expectTypes } from '../util'; + expectTypes } from '../util'; import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; @@ -91,7 +91,6 @@ class SnapshotHelper { testInfo: TestInfoImpl, matcherName: string, locator: Locator | undefined, - snapshotPathResolver: (...pathSegments: string[]) => string, anonymousSnapshotExtension: string, configOptions: ToHaveScreenshotConfigOptions, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions, @@ -165,7 +164,7 @@ class SnapshotHelper { // sanitizes path if string const inputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, '', undefined, true)]; const outputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, actualModifier, undefined, true)]; - this.snapshotPath = snapshotPathResolver(...inputPathSegments); + this.snapshotPath = testInfo.snapshotPath(...inputPathSegments); const inputFile = testInfo._getOutputPath(...inputPathSegments); const outputFile = testInfo._getOutputPath(...outputPathSegments); this.legacyExpectedPath = addSuffixToFilePath(inputFile, '-expected'); @@ -304,10 +303,10 @@ export function toMatchSnapshot( if (testInfo._configInternal.ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toMatchSnapshot', expected: nameOrOptions }; + const configOptions = testInfo._projectInternal.expect?.toMatchSnapshot || {}; const helper = new SnapshotHelper( - testInfo, 'toMatchSnapshot', undefined, testInfo.snapshotPath.bind(testInfo), determineFileExtension(received), - testInfo._projectInternal.expect?.toMatchSnapshot || {}, - nameOrOptions, optOptions); + testInfo, 'toMatchSnapshot', undefined, determineFileExtension(received), + configOptions, nameOrOptions, optOptions); if (this.isNot) { if (!fs.existsSync(helper.snapshotPath)) @@ -362,10 +361,7 @@ export async function toHaveScreenshot( expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const [page, locator] = pageOrLocator.constructor.name === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator]; const configOptions = testInfo._projectInternal.expect?.toHaveScreenshot || {}; - const snapshotPathResolver = testInfo.snapshotPath.bind(testInfo); - const helper = new SnapshotHelper( - testInfo, 'toHaveScreenshot', locator, snapshotPathResolver, 'png', - configOptions, nameOrOptions, optOptions); + const helper = new SnapshotHelper(testInfo, 'toHaveScreenshot', locator, 'png', configOptions, nameOrOptions, optOptions); if (!helper.snapshotPath.toLowerCase().endsWith('.png')) throw new Error(`Screenshot name "${path.basename(helper.snapshotPath)}" must have '.png' extension`); expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); From baf2cdf93636c0d2bafb754f9a2c028d2dfc040b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 29 Feb 2024 19:13:32 -0800 Subject: [PATCH 034/141] fix(ct): stop-gap for shared file import (#29744) Fixes: https://github.com/microsoft/playwright/issues/29739 --- .../playwright-ct-core/src/tsxTransform.ts | 29 ++++++++++++++-- .../playwright.ct-react.spec.ts | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index 0a61d4e3d9..ab33c114ba 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -75,7 +75,7 @@ export default declare((api: BabelAPI) => { const ext = path.extname(importNode.source.value); // Convert all non-JS imports into refs. - if (!allJsExtensions.has(ext)) { + if (artifactExtensions.has(ext)) { for (const specifier of importNode.specifiers) { if (t.isImportNamespaceSpecifier(specifier)) continue; @@ -171,4 +171,29 @@ export function importInfo(importNode: T.ImportDeclaration, specifier: T.ImportS return { localName: specifier.local.name, info: result }; } -const allJsExtensions = new Set(['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx', '.cts', '.mts', '']); +const artifactExtensions = new Set([ + // Frameworks + '.vue', + '.svelte', + + // Images + '.jpg', '.jpeg', + '.png', + '.gif', + '.svg', + '.bmp', + '.webp', + '.ico', + + // CSS + '.css', + + // Fonts + '.woff', '.woff2', + '.ttf', + '.otf', + '.eot', + + // Other assets + '.json', +]); \ No newline at end of file diff --git a/tests/playwright-test/playwright.ct-react.spec.ts b/tests/playwright-test/playwright.ct-react.spec.ts index a92f8297f1..6f1780ce55 100644 --- a/tests/playwright-test/playwright.ct-react.spec.ts +++ b/tests/playwright-test/playwright.ct-react.spec.ts @@ -511,3 +511,37 @@ test('should allow props children', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should allow import from shared file', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': playwrightCtConfigText, + 'playwright/index.html': ``, + 'playwright/index.ts': ``, + 'src/component.tsx': ` + export const Component = (props: { content: string }) => { + return
{props.content}
+ }; + `, + 'src/component.shared.tsx': ` + export const componentMock = { content: 'This is a content.' }; + `, + 'src/component.render.tsx': ` + import {Component} from './component'; + import {componentMock} from './component.shared'; + export const ComponentTest = () => { + return ; + }; + `, + 'src/component.spec.tsx': ` + import { expect, test } from '@playwright/experimental-ct-react'; + import { ComponentTest } from './component.render'; + import { componentMock } from './component.shared'; + test('component renders', async ({ mount }) => { + const component = await mount(); + await expect(component).toContainText(componentMock.content) + })` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); From 8780f7ad46ea0cd54b435247a2c544252dbbb18a Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 1 Mar 2024 00:31:56 -0800 Subject: [PATCH 035/141] feat(chromium-tip-of-tree): roll to r1197 (#29753) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index f936b9c657..0dec177df4 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,9 +15,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1196", + "revision": "1197", "installByDefault": false, - "browserVersion": "124.0.6315.0" + "browserVersion": "124.0.6325.0" }, { "name": "firefox", From ceb1dd01aaeb45f5bf5c5f66c60f174b13ce93ca Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 1 Mar 2024 05:29:12 -0800 Subject: [PATCH 036/141] feat(chromium-tip-of-tree): roll to r1198 (#29756) Signed-off-by: Max Schmitt Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Max Schmitt --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 0dec177df4..98315f67db 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,9 +15,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1197", + "revision": "1198", "installByDefault": false, - "browserVersion": "124.0.6325.0" + "browserVersion": "124.0.6329.0" }, { "name": "firefox", From f8e441a52182043956c7277e48467e2d9230d796 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 1 Mar 2024 07:38:09 -0800 Subject: [PATCH 037/141] feat(chromium): roll to r1107 (#29762) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 8 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 14737d10d4..b8f66a4c18 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-123.0.6312.4-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-123.0.6312.22-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 123.0.6312.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 123.0.6312.22 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 123.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 98315f67db..b5c590e384 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1105", + "revision": "1107", "installByDefault": true, - "browserVersion": "123.0.6312.4" + "browserVersion": "123.0.6312.22" }, { "name": "chromium-with-symbols", - "revision": "1105", + "revision": "1107", "installByDefault": false, - "browserVersion": "123.0.6312.4" + "browserVersion": "123.0.6312.22" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 6191ef5d2e..cd9193a3d6 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -978,7 +978,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -989,7 +989,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1000,7 +1000,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1011,7 +1011,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1022,7 +1022,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1033,7 +1033,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1044,7 +1044,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1055,7 +1055,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1066,7 +1066,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1077,7 +1077,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1088,7 +1088,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1099,7 +1099,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1110,7 +1110,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1121,7 +1121,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1132,7 +1132,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1143,7 +1143,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1154,7 +1154,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1165,7 +1165,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1176,7 +1176,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1187,7 +1187,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1242,7 +1242,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1253,7 +1253,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1264,7 +1264,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1275,7 +1275,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1286,7 +1286,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1297,7 +1297,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1308,7 +1308,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1319,7 +1319,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1330,7 +1330,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1345,7 +1345,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1360,7 +1360,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1375,7 +1375,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1390,7 +1390,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1405,7 +1405,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1420,7 +1420,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1431,7 +1431,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1442,7 +1442,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1457,7 +1457,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36 Edg/123.0.6312.4", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36 Edg/123.0.6312.22", "screen": { "width": 1792, "height": 1120 @@ -1502,7 +1502,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1517,7 +1517,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.4 Safari/537.36 Edg/123.0.6312.4", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36 Edg/123.0.6312.22", "screen": { "width": 1920, "height": 1080 From ff16d7960cbad508c07f299fd8b45185328fec6f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Mar 2024 09:36:04 -0800 Subject: [PATCH 038/141] fix(tsload): fix tsconfig inheritance resolution (#29766) Fixes https://github.com/microsoft/playwright/issues/29731 --- packages/playwright/src/third_party/tsconfig-loader.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index ffe8af1a00..d14b7602b0 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -101,7 +101,8 @@ function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) referencedConfigFile += '.json'; const currentDir = path.dirname(baseConfigFile); let resolvedConfigFile = path.resolve(currentDir, referencedConfigFile); - if (referencedConfigFile.indexOf('/') !== -1 && referencedConfigFile.indexOf('.') !== -1 && !fs.existsSync(referencedConfigFile)) + // TODO: I don't see how this makes sense, delete in the next minor release. + if (referencedConfigFile.includes('/') && referencedConfigFile.includes('.') && !fs.existsSync(resolvedConfigFile)) resolvedConfigFile = path.join(currentDir, 'node_modules', referencedConfigFile); return resolvedConfigFile; } @@ -117,6 +118,7 @@ function loadTsConfig( let result: LoadedTsConfig = { tsConfigPath: configFilePath, }; + // Retain result instance below, so that caching works. visited.set(configFilePath, result); if (!fs.existsSync(configFilePath)) @@ -137,7 +139,8 @@ function loadTsConfig( const extendsDir = path.dirname(extendedConfig); base.baseUrl = path.join(extendsDir, base.baseUrl); } - result = { ...result, ...base, tsConfigPath: configFilePath }; + // Retain result instance, so that caching works. + Object.assign(result, base, { tsConfigPath: configFilePath }); } const loadedConfig = Object.fromEntries(Object.entries({ @@ -146,7 +149,8 @@ function loadTsConfig( allowJs: parsedConfig?.compilerOptions?.allowJs, }).filter(([, value]) => value !== undefined)); - result = { ...result, ...loadedConfig }; + // Retain result instance, so that caching works. + Object.assign(result, loadedConfig); for (const ref of parsedConfig.references || []) references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited)); From a8c26d235c7a65946754299d4fa010a560ef61de Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 1 Mar 2024 09:37:28 -0800 Subject: [PATCH 039/141] Revert "chore(role): cache element list by role (#29130)" (#29765) This reverts commit 1ce3ca25a2676ca773a736035699cf3fe0c35d0d. Added a regression test. Fixes #29760. --- .../src/server/injected/roleSelectorEngine.ts | 47 ++++++++++++------- .../src/server/injected/roleUtils.ts | 42 ----------------- tests/page/selectors-role.spec.ts | 17 +++++++ 3 files changed, 47 insertions(+), 59 deletions(-) diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 56643199e5..b647f81b8e 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -16,10 +16,9 @@ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; import { matchesAttributePart } from './selectorUtils'; -import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaSelected, getElementAccessibleName, getElementsByRole, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../../utils/isomorphic/selectorParser'; import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; -import { isInsideScope } from './domUtils'; type RoleEngineOptions = { role: string; @@ -126,27 +125,26 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE } function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] { - const doc = scope.nodeType === 9 /* Node.DOCUMENT_NODE */ ? scope as Document : scope.ownerDocument; - const elements = doc ? getElementsByRole(doc, options.role) : []; - return elements.filter(element => { - if (!isInsideScope(scope, element)) - return false; + const result: Element[] = []; + const match = (element: Element) => { + if (getAriaRole(element) !== options.role) + return; if (options.selected !== undefined && getAriaSelected(element) !== options.selected) - return false; + return; if (options.checked !== undefined && getAriaChecked(element) !== options.checked) - return false; + return; if (options.pressed !== undefined && getAriaPressed(element) !== options.pressed) - return false; + return; if (options.expanded !== undefined && getAriaExpanded(element) !== options.expanded) - return false; + return; if (options.level !== undefined && getAriaLevel(element) !== options.level) - return false; + return; if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled) - return false; + return; if (!options.includeHidden) { const isHidden = isElementHiddenForAria(element); if (isHidden) - return false; + return; } if (options.name !== undefined) { // Always normalize whitespace in the accessible name. @@ -157,10 +155,25 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo if (internal && !options.exact && options.nameOp === '=') options.nameOp = '*='; if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact })) - return false; + return; } - return true; - }); + result.push(element); + }; + + const query = (root: Element | ShadowRoot | Document) => { + const shadows: ShadowRoot[] = []; + if ((root as Element).shadowRoot) + shadows.push((root as Element).shadowRoot!); + for (const element of root.querySelectorAll('*')) { + match(element); + if (element.shadowRoot) + shadows.push(element.shadowRoot); + } + shadows.forEach(query); + }; + + query(scope); + return result; } export function createRoleEngine(internal: boolean): SelectorEngine { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index a42b60233e..6cecb1d5a5 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -845,51 +845,11 @@ function getAccessibleNameFromAssociatedLabels(labels: Iterable !!accessibleName).join(' '); } -export function getElementsByRole(document: Document, role: string): Element[] { - if (document === cacheElementsByRoleDocument) - return cacheElementsByRole!.get(role) || []; - const map = calculateElementsByRoleMap(document); - if (cachesCounter) { - cacheElementsByRoleDocument = document; - cacheElementsByRole = map; - } - return map.get(role) || []; -} - -function calculateElementsByRoleMap(document: Document) { - const result = new Map(); - - const visit = (root: Element | ShadowRoot | Document) => { - const shadows: ShadowRoot[] = []; - if ((root as Element).shadowRoot) - shadows.push((root as Element).shadowRoot!); - for (const element of root.querySelectorAll('*')) { - const role = getAriaRole(element); - if (role) { - let list = result.get(role); - if (!list) { - list = []; - result.set(role, list); - } - list.push(element); - } - if (element.shadowRoot) - shadows.push(element.shadowRoot); - } - shadows.forEach(visit); - }; - visit(document); - - return result; -} - let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; -let cacheElementsByRole: Map | undefined; -let cacheElementsByRoleDocument: Document | undefined; let cachesCounter = 0; export function beginAriaCaches() { @@ -908,7 +868,5 @@ export function endAriaCaches() { cacheIsHidden = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; - cacheElementsByRole = undefined; - cacheElementsByRoleDocument = undefined; } } diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index d936f1b229..f5680982b1 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -484,3 +484,20 @@ test('should support output accessible name', async ({ page }) => { await page.setContent(``); await expect(page.getByRole('status', { name: 'Output1' })).toBeVisible(); }); + +test('should not match scope by default', async ({ page }) => { + await page.setContent(` +
    +
  • + Parent list +
      +
    • child 1
    • +
    • child 2
    • +
    +
  • +
+ `); + const children = page.getByRole('listitem', { name: 'Parent list' }).getByRole('listitem'); + await expect(children).toHaveCount(2); + await expect(children).toHaveText(['child 1', 'child 2']); +}); From 4e0bd6286eb49719d125b2f37d114d83bfc7833c Mon Sep 17 00:00:00 2001 From: Renan Greca Date: Fri, 1 Mar 2024 19:51:23 +0100 Subject: [PATCH 040/141] docs: Improved JSDoc for expect.toPass (#29722) --- packages/playwright/types/test.d.ts | 19 ++++++++++++++++++- utils/generate_types/overrides-test.d.ts | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 11ceb7153a..1e610baf53 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6957,7 +6957,24 @@ interface GenericAssertions { type FunctionAssertions = { /** - * Retries the callback until it passes. + * Retries the callback until all assertions within it pass or the `timeout` value is reached. + * The `intervals` parameter can be used to establish the probing frequency or pattern. + * + * **Usage** + * ```js + * await expect(async () => { + * const response = await page.request.get('https://api.example.com'); + * expect(response.status()).toBe(200); + * }).toPass({ + * // Probe, wait 1s, probe, wait 2s, probe, wait 10s, probe, wait 10s, probe + * intervals: [1_000, 2_000, 10_000], // Defaults to [100, 250, 500, 1000]. + * timeout: 60_000 // Defaults to 0 + * }); + * ``` + * + * Note that by default `toPass` does not respect custom expect timeout. + * + * @param options */ toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index eed775d21f..6cafdcc932 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -341,7 +341,24 @@ interface GenericAssertions { type FunctionAssertions = { /** - * Retries the callback until it passes. + * Retries the callback until all assertions within it pass or the `timeout` value is reached. + * The `intervals` parameter can be used to establish the probing frequency or pattern. + * + * **Usage** + * ```js + * await expect(async () => { + * const response = await page.request.get('https://api.example.com'); + * expect(response.status()).toBe(200); + * }).toPass({ + * // Probe, wait 1s, probe, wait 2s, probe, wait 10s, probe, wait 10s, probe + * intervals: [1_000, 2_000, 10_000], // Defaults to [100, 250, 500, 1000]. + * timeout: 60_000 // Defaults to 0 + * }); + * ``` + * + * Note that by default `toPass` does not respect custom expect timeout. + * + * @param options */ toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; From ba3d887660dfc8dd3ff3412b0151576e196e763a Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 1 Mar 2024 11:19:37 -0800 Subject: [PATCH 041/141] docs: improve addLocatorHandler docs (#29770) --- docs/src/api/class-page.md | 18 +++++----- packages/playwright-core/types/types.d.ts | 43 ++++++++++++----------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 922a7df3bf..c0cd3d0bea 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3146,27 +3146,29 @@ return value resolves to `[]`. ## async method: Page.addLocatorHandler * since: v1.42 -Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent dialogs behave this way. In this case, [`method: Page.addLocatorHandler`] allows handling an overlay during an action that it would block. +When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making them tricky to handle in automated tests. -This method registers a handler for an overlay that is executed once the locator is visible on the page. The handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for nondeterministic interstitial pages or dialogs, like a cookie consent dialog. +This method lets you set up a special function, called a handler, that activates when it detects that overlay is visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. -Note that execution time of the handler counts towards the timeout of the action/assertion that executed the handler. - -You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a handler must not require another handler to run. +Things to keep in mind: +* When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as a part of your normal test flow, instead of using [`method: Page.addLocatorHandler`]. +* Playwright checks for the overlay every time before executing or retrying an action that requires an [actionability check](../actionability.md), or before performing an auto-waiting assertion check. When overlay is visible, Playwright calls the handler first, and then proceeds with the action/assertion. +* The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts. +* You can register multiple handlers. However, only a single handler will be running at a time. Make sure the actions within a handler don't depend on another handler. :::warning -Running the interceptor will alter your page state mid-test. For example it will change the currently focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained and do not rely on the focus and mouse state. +Running the handler will alter your page state mid-test. For example it will change the currently focused element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on the focus and mouse state being unchanged.

For example, consider a test that calls [`method: Locator.focus`] followed by [`method: Keyboard.press`]. If your handler clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use [`method: Locator.press`] instead to avoid this problem.

-Another example is a series of mouse actions, where [`method: Mouse.move`] is followed by [`method: Mouse.down`]. Again, when the handler runs between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like [`method: Locator.click`] that are self-contained. +Another example is a series of mouse actions, where [`method: Mouse.move`] is followed by [`method: Mouse.down`]. Again, when the handler runs between these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions like [`method: Locator.click`] that do not rely on the state being unchanged by a handler. ::: **Usage** -An example that closes a cookie dialog when it appears: +An example that closes a cookie consent dialog when it appears: ```js // Setup the handler. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 95c0ddcdd0..e8f1bb9c20 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -1781,26 +1781,28 @@ export interface Page { prependListener(event: 'worker', listener: (worker: Worker) => void): this; /** - * Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like - * click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your - * test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent - * dialogs behave this way. In this case, - * [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler) - * allows handling an overlay during an action that it would block. + * When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you + * want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, + * making them tricky to handle in automated tests. * - * This method registers a handler for an overlay that is executed once the locator is visible on the page. The - * handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for - * nondeterministic interstitial pages or dialogs, like a cookie consent dialog. + * This method lets you set up a special function, called a handler, that activates when it detects that overlay is + * visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. * - * Note that execution time of the handler counts towards the timeout of the action/assertion that executed the - * handler. + * Things to keep in mind: + * - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as + * a part of your normal test flow, instead of using + * [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler). + * - Playwright checks for the overlay every time before executing or retrying an action that requires an + * [actionability check](https://playwright.dev/docs/actionability), or before performing an auto-waiting assertion check. When overlay + * is visible, Playwright calls the handler first, and then proceeds with the action/assertion. + * - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + * If your handler takes too long, it might cause timeouts. + * - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the + * actions within a handler don't depend on another handler. * - * You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a - * handler must not require another handler to run. - * - * **NOTE** Running the interceptor will alter your page state mid-test. For example it will change the currently - * focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained - * and do not rely on the focus and mouse state.

For example, consider a test that calls + * **NOTE** Running the handler will alter your page state mid-test. For example it will change the currently focused + * element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on + * the focus and mouse state being unchanged.

For example, consider a test that calls * [locator.focus([options])](https://playwright.dev/docs/api/class-locator#locator-focus) followed by * [keyboard.press(key[, options])](https://playwright.dev/docs/api/class-keyboard#keyboard-press). If your handler * clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen @@ -1809,12 +1811,13 @@ export interface Page { * problem.

Another example is a series of mouse actions, where * [mouse.move(x, y[, options])](https://playwright.dev/docs/api/class-mouse#mouse-move) is followed by * [mouse.down([options])](https://playwright.dev/docs/api/class-mouse#mouse-down). Again, when the handler runs - * between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like - * [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) that are self-contained. + * between these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions + * like [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) that do not rely on + * the state being unchanged by a handler. * * **Usage** * - * An example that closes a cookie dialog when it appears: + * An example that closes a cookie consent dialog when it appears: * * ```js * // Setup the handler. From d0cc5871d876df8bdbd15488079a76f455acab3a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Mar 2024 11:30:28 -0800 Subject: [PATCH 042/141] chore: remove private suite from tele reporter (#29752) --- packages/playwright/src/common/suiteUtils.ts | 3 ++- packages/playwright/src/common/test.ts | 3 +-- .../playwright/src/isomorphic/teleReceiver.ts | 26 +++++++++---------- packages/playwright/src/reporters/base.ts | 17 +++++------- packages/playwright/src/reporters/html.ts | 18 ++++++++----- .../playwright/src/reporters/teleEmitter.ts | 8 ++---- packages/playwright/types/reporterPrivate.ts | 22 ---------------- 7 files changed, 36 insertions(+), 61 deletions(-) delete mode 100644 packages/playwright/types/reporterPrivate.ts diff --git a/packages/playwright/src/common/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index c2140a8a96..81dc9d5465 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -90,7 +90,8 @@ export function applyRepeatEachIndex(project: FullProjectInternal, fileSuite: Su // Assign test properties with project-specific values. fileSuite.forEachTest((test, suite) => { if (repeatEachIndex) { - const testIdExpression = `[project=${project.id}]${test.titlePath().join('\x1e')} (repeat:${repeatEachIndex})`; + const [file, ...titles] = test.titlePath(); + const testIdExpression = `[project=${project.id}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${repeatEachIndex})`; const testId = suite._fileId + '-' + calculateSha1(testIdExpression).slice(0, 20); test.id = testId; test.repeatEachIndex = repeatEachIndex; diff --git a/packages/playwright/src/common/test.ts b/packages/playwright/src/common/test.ts index ec67168826..0d05ac5dfd 100644 --- a/packages/playwright/src/common/test.ts +++ b/packages/playwright/src/common/test.ts @@ -16,7 +16,6 @@ import type { FixturePool } from './fixtures'; import type * as reporterTypes from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import type { TestTypeImpl } from './testType'; import { rootTestType } from './testType'; import type { Annotation, FixturesWithLocation, FullProjectInternal } from './config'; @@ -40,7 +39,7 @@ export type Modifier = { description: string | undefined }; -export class Suite extends Base implements SuitePrivate { +export class Suite extends Base { location?: Location; parent?: Suite; _use: FixturesWithLocation[] = []; diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index ce6834844e..9624a76876 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -17,7 +17,6 @@ import type { Annotation } from '../common/config'; import type { FullProject, Metadata } from '../../types/test'; import type * as reporterTypes from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import type { ReporterV2 } from '../reporters/reporterV2'; import { StringInternPool } from './stringInternPool'; @@ -45,12 +44,15 @@ export type JsonProject = { metadata: Metadata; name: string; dependencies: string[]; + // This is relative to root dir. snapshotDir: string; + // This is relative to root dir. outputDir: string; repeatEach: number; retries: number; suites: JsonSuite[]; teardown?: string; + // This is relative to root dir. testDir: string; testIgnore: JsonPattern[]; testMatch: JsonPattern[]; @@ -58,13 +60,10 @@ export type JsonProject = { }; export type JsonSuite = { - type: 'root' | 'project' | 'file' | 'describe'; title: string; location?: JsonLocation; suites: JsonSuite[]; tests: JsonTestCase[]; - fileId: string | undefined; - parallelMode: 'none' | 'default' | 'serial' | 'parallel'; }; export type JsonTestCase = { @@ -73,6 +72,7 @@ export type JsonTestCase = { location: JsonLocation; retries: number; tags?: string[]; + repeatEachIndex: number; }; export type JsonTestEnd = { @@ -365,13 +365,11 @@ export class TeleReporterReceiver { for (const jsonSuite of jsonSuites) { let targetSuite = parent.suites.find(s => s.title === jsonSuite.title); if (!targetSuite) { - targetSuite = new TeleSuite(jsonSuite.title, jsonSuite.type); + targetSuite = new TeleSuite(jsonSuite.title, parent._type === 'project' ? 'file' : 'describe'); targetSuite.parent = parent; parent.suites.push(targetSuite); } targetSuite.location = this._absoluteLocation(jsonSuite.location); - targetSuite._fileId = jsonSuite.fileId; - targetSuite._parallelMode = jsonSuite.parallelMode; this._mergeSuitesInto(jsonSuite.suites, targetSuite); this._mergeTestsInto(jsonSuite.tests, targetSuite); } @@ -379,9 +377,9 @@ export class TeleReporterReceiver { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) { for (const jsonTest of jsonTests) { - let targetTest = this._reuseTestCases ? parent.tests.find(s => s.title === jsonTest.title) : undefined; + let targetTest = this._reuseTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; if (!targetTest) { - targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location)); + targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location), jsonTest.repeatEachIndex); targetTest.parent = parent; parent.tests.push(targetTest); this._tests.set(targetTest.id, targetTest); @@ -412,14 +410,14 @@ export class TeleReporterReceiver { private _absolutePath(relativePath: string): string; private _absolutePath(relativePath?: string): string | undefined; private _absolutePath(relativePath?: string): string | undefined { - if (!relativePath) - return relativePath; + if (relativePath === undefined) + return; return this._stringPool.internString(this._rootDir + this._pathSeparator + relativePath); } } -export class TeleSuite implements SuitePrivate { +export class TeleSuite { title: string; location?: reporterTypes.Location; parent?: TeleSuite; @@ -428,7 +426,6 @@ export class TeleSuite implements SuitePrivate { tests: TeleTestCase[] = []; _timeout: number | undefined; _retries: number | undefined; - _fileId: string | undefined; _project: TeleFullProject | undefined; _parallelMode: 'none' | 'default' | 'serial' | 'parallel' = 'none'; readonly _type: 'root' | 'project' | 'file' | 'describe'; @@ -482,10 +479,11 @@ export class TeleTestCase implements reporterTypes.TestCase { resultsMap = new Map(); - constructor(id: string, title: string, location: reporterTypes.Location) { + constructor(id: string, title: string, location: reporterTypes.Location, repeatEachIndex: number) { this.id = id; this.title = title; this.location = location; + this.repeatEachIndex = repeatEachIndex; } titlePath(): string[] { diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 91f34faf74..fff2703208 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -17,7 +17,6 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'playwright-core/lib/utilsBundle'; import path from 'path'; import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; import type { ReporterV2 } from './reporterV2'; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; @@ -72,7 +71,7 @@ export class BaseReporter implements ReporterV2 { suite!: Suite; totalTestCount = 0; result!: FullResult; - private fileDurations = new Map(); + private fileDurations = new Map }>(); private _omitFailures: boolean; private _fatalErrors: TestError[] = []; private _failureCount: number = 0; @@ -115,16 +114,13 @@ export class BaseReporter implements ReporterV2 { onTestEnd(test: TestCase, result: TestResult) { if (result.status !== 'skipped' && result.status !== test.expectedStatus) ++this._failureCount; - // Ignore any tests that are run in parallel. - for (let suite: Suite | undefined = test.parent; suite; suite = suite.parent) { - if ((suite as SuitePrivate)._parallelMode === 'parallel') - return; - } const projectName = test.titlePath()[1]; const relativePath = relativeTestPath(this.config, test); const fileAndProject = (projectName ? `[${projectName}] › ` : '') + relativePath; - const duration = this.fileDurations.get(fileAndProject) || 0; - this.fileDurations.set(fileAndProject, duration + result.duration); + const entry = this.fileDurations.get(fileAndProject) || { duration: 0, workers: new Set() }; + entry.duration += result.duration; + entry.workers.add(result.workerIndex); + this.fileDurations.set(fileAndProject, entry); } onError(error: TestError) { @@ -167,7 +163,8 @@ export class BaseReporter implements ReporterV2 { protected getSlowTests(): [string, number][] { if (!this.config.reportSlowTests) return []; - const fileDurations = [...this.fileDurations.entries()]; + // Only pick durations that were served by single worker. + const fileDurations = [...this.fileDurations.entries()].filter(([key, value]) => value.workers.size === 1).map(([key, value]) => [key, value.duration]) as [string, number][]; fileDurations.sort((a, b) => b[1] - a[1]); const count = Math.min(fileDurations.length, this.config.reportSlowTests.max || Number.POSITIVE_INFINITY); const threshold = this.config.reportSlowTests.threshold; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 3f21de3cde..4184273e1d 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -22,7 +22,6 @@ import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; import { codeFrameColumns } from '../transform/babelBundle'; import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic, TestError } from '../../types/testReporter'; -import type { SuitePrivate } from '../../types/reporterPrivate'; import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils'; import { colors, formatError, formatResultFailure, stripAnsiEscapes } from './base'; import { resolveReporterOutputPath } from '../util'; @@ -227,9 +226,12 @@ class HtmlBuilder { async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); for (const projectSuite of projectSuites) { + const testDir = projectSuite.project()!.testDir; for (const fileSuite of projectSuite.suites) { const fileName = this._relativeLocation(fileSuite.location)!.file; - const fileId = (fileSuite as SuitePrivate)._fileId!; + // Preserve file ids computed off the testDir. + const relativeFile = path.relative(testDir, fileSuite.location!.file); + const fileId = calculateSha1(toPosixPath(relativeFile)).slice(0, 20); let fileEntry = data.get(fileId); if (!fileEntry) { fileEntry = { @@ -343,19 +345,23 @@ class HtmlBuilder { private _processJsonSuite(suite: Suite, fileId: string, projectName: string, botName: string | undefined, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, botName, newPath, outTests)); - suite.tests.forEach(t => outTests.push(this._createTestEntry(t, projectName, botName, newPath))); + suite.tests.forEach(t => outTests.push(this._createTestEntry(fileId, t, projectName, botName, newPath))); } - private _createTestEntry(test: TestCasePublic, projectName: string, botName: string | undefined, path: string[]): TestEntry { + private _createTestEntry(fileId: string, test: TestCasePublic, projectName: string, botName: string | undefined, path: string[]): TestEntry { const duration = test.results.reduce((a, r) => a + r.duration, 0); const location = this._relativeLocation(test.location)!; path = path.slice(1); + const [file, ...titles] = test.titlePath(); + const testIdExpression = `[project=${projectName}]${toPosixPath(file)}\x1e${titles.join('\x1e')} (repeat:${test.repeatEachIndex})`; + const testId = fileId + '-' + calculateSha1(testIdExpression).slice(0, 20); + const results = test.results.map(r => this._createTestResult(test, r)); return { testCase: { - testId: test.id, + testId, title: test.title, projectName, botName, @@ -370,7 +376,7 @@ class HtmlBuilder { ok: test.outcome() === 'expected' || test.outcome() === 'flaky', }, testCaseSummary: { - testId: test.id, + testId, title: test.title, projectName, botName, diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index 7f4caf6ad5..e6bc49ee3e 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -16,10 +16,8 @@ import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; -import type { SuitePrivate } from '../../types/reporterPrivate'; -import type { FullConfig, FullResult, Location, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; +import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; import { FullConfigInternal, getProjectId } from '../common/config'; -import type { Suite } from '../common/test'; import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; @@ -185,10 +183,7 @@ export class TeleReporterEmitter implements ReporterV2 { private _serializeSuite(suite: Suite): JsonSuite { const result = { - type: suite._type, title: suite.title, - fileId: (suite as SuitePrivate)._fileId, - parallelMode: (suite as SuitePrivate)._parallelMode, location: this._relativeLocation(suite.location), suites: suite.suites.map(s => this._serializeSuite(s)), tests: suite.tests.map(t => this._serializeTest(t)), @@ -203,6 +198,7 @@ export class TeleReporterEmitter implements ReporterV2 { location: this._relativeLocation(test.location), retries: test.retries, tags: test.tags, + repeatEachIndex: test.repeatEachIndex, }; } diff --git a/packages/playwright/types/reporterPrivate.ts b/packages/playwright/types/reporterPrivate.ts deleted file mode 100644 index 198b2fe9ea..0000000000 --- a/packages/playwright/types/reporterPrivate.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Suite } from './testReporter'; - -export interface SuitePrivate extends Suite { - _fileId: string | undefined; - _parallelMode: 'none' | 'default' | 'serial' | 'parallel'; -} From bbcc3c1238b02aa8a446dd60511e630a8c6c644e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Mar 2024 13:14:12 -0800 Subject: [PATCH 043/141] chore: remove private config usage from telereporter (#29771) --- packages/playwright/src/common/config.ts | 5 ----- packages/playwright/src/isomorphic/teleReceiver.ts | 8 +++----- packages/playwright/src/reporters/html.ts | 5 ++--- packages/playwright/src/reporters/merge.ts | 3 +-- packages/playwright/src/reporters/teleEmitter.ts | 3 +-- packages/playwright/src/runner/reporters.ts | 5 +++-- packages/playwright/src/runner/uiMode.ts | 4 ++-- packages/trace-viewer/src/ui/uiModeView.tsx | 13 +++++++++---- tests/playwright-test/reporter-blob.spec.ts | 6 +++--- 9 files changed, 24 insertions(+), 28 deletions(-) diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index d1c86fe159..cfe294a75c 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -58,11 +58,6 @@ export class FullConfigInternal { testIdMatcher?: Matcher; defineConfigWasUsed = false; - // TODO: when merging reports, there could be no internal config. This is very unfortunate. - static from(config: FullConfig): FullConfigInternal | undefined { - return (config as any)[configInternalSymbol]; - } - constructor(location: ConfigLocation, userConfig: Config, configCLIOverrides: ConfigCLIOverrides) { if (configCLIOverrides.projects && userConfig.projects) throw new Error(`Cannot use --browser option when configuration file defines projects. Specify browserName in the projects instead.`); diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index 9624a76876..a7f9f412f1 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -26,9 +26,7 @@ export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonStdIOType = 'stdout' | 'stderr'; -export type JsonConfig = Pick & { - listOnly: boolean; -}; +export type JsonConfig = Pick; export type MergeReporterConfig = Pick; @@ -147,10 +145,11 @@ export class TeleReporterReceiver { this._reportConfig = reportConfig; } - dispatch(message: JsonEvent): Promise | void { + dispatch(mode: 'list' | 'test', message: JsonEvent): Promise | void { const { method, params } = message; if (method === 'onConfigure') { this._onConfigure(params.config); + this._listOnly = mode === 'list'; return; } if (method === 'onProject') { @@ -197,7 +196,6 @@ export class TeleReporterReceiver { private _onConfigure(config: JsonConfig) { this._rootDir = config.rootDir; - this._listOnly = config.listOnly; this._config = this._parseConfig(config); this._reporter.onConfigure?.(this._config); } diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 4184273e1d..8c0adcd5c0 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -30,7 +30,6 @@ import type { ZipFile } from 'playwright-core/lib/zipBundle'; import { yazl } from 'playwright-core/lib/zipBundle'; import { mime } from 'playwright-core/lib/utilsBundle'; import type { HTMLReport, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types'; -import { FullConfigInternal } from '../common/config'; import EmptyReporter from './empty'; type TestEntry = { @@ -52,6 +51,7 @@ type HtmlReporterOptions = { host?: string, port?: number, attachmentsBaseURL?: string, + _mode?: string; }; class HtmlReporter extends EmptyReporter { @@ -124,12 +124,11 @@ class HtmlReporter extends EmptyReporter { override async onExit() { if (process.env.CI || !this._buildResult) return; - const { ok, singleTestId } = this._buildResult; const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure'); if (shouldOpen) { await showHTMLReport(this._outputFolder, this._options.host, this._options.port, singleTestId); - } else if (!FullConfigInternal.from(this.config)?.cliListOnly) { + } else if (this._options._mode === 'run') { const packageManagerCommand = getPackageManagerExecCommand(); const relativeReportPath = this._outputFolder === standaloneDefaultFolder() ? '' : ' ' + path.relative(process.cwd(), this._outputFolder); const hostArg = this._options.host ? ` --host ${this._options.host}` : ''; diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 9e81f92e7a..096cc66c50 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -60,7 +60,7 @@ export async function createMergedReport(config: FullConfigInternal, dir: string for (const event of events) { if (event.method === 'onEnd') printStatus(`building final report`); - await receiver.dispatch(event); + await receiver.dispatch('test', event); if (event.method === 'onEnd') printStatus(`finished building report`); } @@ -248,7 +248,6 @@ function mergeConfigureEvents(configureEvents: JsonEvent[], rootDirOverride: str rootDir: '', version: '', workers: 0, - listOnly: false }; for (const event of configureEvents) config = mergeConfigs(config, event.params.config); diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index e6bc49ee3e..44a9908300 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -17,7 +17,7 @@ import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; -import { FullConfigInternal, getProjectId } from '../common/config'; +import { getProjectId } from '../common/config'; import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; @@ -152,7 +152,6 @@ export class TeleReporterEmitter implements ReporterV2 { rootDir: config.rootDir, version: config.version, workers: config.workers, - listOnly: !!FullConfigInternal.from(config)?.cliListOnly, }; } diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 2285e9bee2..033356e1bd 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -50,9 +50,10 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | descriptions ??= config.config.reporter; if (config.configCLIOverrides.additionalReporters) descriptions = [...descriptions, ...config.configCLIOverrides.additionalReporters]; + const runOptions = { configDir: config.configDir, _mode: mode }; for (const r of descriptions) { const [name, arg] = r; - const options = { ...arg, configDir: config.configDir }; + const options = { ...runOptions, ...arg }; if (name in defaultReporters) { reporters.push(new defaultReporters[name as keyof typeof defaultReporters](options)); } else { @@ -62,7 +63,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | } if (process.env.PW_TEST_REPORTER) { const reporterConstructor = await loadReporter(config, process.env.PW_TEST_REPORTER); - reporters.push(wrapReporterAsV2(new reporterConstructor())); + reporters.push(wrapReporterAsV2(new reporterConstructor(runOptions))); } const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio()); diff --git a/packages/playwright/src/runner/uiMode.ts b/packages/playwright/src/runner/uiMode.ts index d0071e14f1..61a5aaddce 100644 --- a/packages/playwright/src/runner/uiMode.ts +++ b/packages/playwright/src/runner/uiMode.ts @@ -167,7 +167,7 @@ class UIMode { } private async _listTests() { - const reporter = new InternalReporter(new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params), true)); + const reporter = new InternalReporter(new TeleReporterEmitter(e => this._dispatchEvent('listReport', e), true)); this._config.cliListOnly = true; this._config.testIdMatcher = undefined; const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process', { failOnLoadErrors: false }); @@ -195,7 +195,7 @@ class UIMode { this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); const reporters = await createReporters(this._config, 'ui'); - reporters.push(new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params), true)); + reporters.push(new TeleReporterEmitter(e => this._dispatchEvent('testReport', e), true)); const reporter = new InternalReporter(new Multiplexer(reporters)); const taskRunner = createTaskRunnerForWatch(this._config, reporter); const testRun = new TestRun(this._config, reporter); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 1becb6645f..89e05f5b48 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -737,10 +737,15 @@ const dispatchEvent = (method: string, params?: any) => { return; } - // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` - // before we use it. - lastRunReceiver?.dispatch({ method, params })?.catch(() => {}); - receiver?.dispatch({ method, params })?.catch(() => {}); + if (method === 'listReport') + receiver?.dispatch('list', params)?.catch(() => {}); + + if (method === 'testReport') { + // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` + // before we use it. + lastRunReceiver?.dispatch('test', params)?.catch(() => {}); + receiver?.dispatch('test', params)?.catch(() => {}); + } }; const outputDirForTestCase = (testCase: TestCase): string | undefined => { diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index f336e916ff..15c17743e4 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -212,7 +212,7 @@ test('should merge into html with dependencies', async ({ runInlineTest, mergeRe const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); @@ -377,7 +377,7 @@ test('total time is from test run not from merge', async ({ runInlineTest, merge const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); @@ -1152,7 +1152,7 @@ test('preserve steps in html report', async ({ runInlineTest, mergeReports, show const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'], cwd: mergeCwd }); expect(exitCode).toBe(0); - expect(output).toContain('To open last HTML report run:'); + expect(output).not.toContain('To open last HTML report run:'); await showReport(); From ef924c14e7edbf62d2fcb2a75f024aff8bf5b9f1 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Mar 2024 21:44:08 -0800 Subject: [PATCH 044/141] chore: do not use project id in telereporter (#29776) --- .github/workflows/tests_primary.yml | 4 +- .github/workflows/tests_secondary.yml | 16 ++++---- .github/workflows/tests_service.yml | 2 +- .github/workflows/tests_video.yml | 2 +- package.json | 6 +-- packages/html-reporter/src/filter.ts | 5 +-- packages/html-reporter/src/labelUtils.tsx | 16 -------- packages/html-reporter/src/testCaseView.tsx | 4 +- packages/html-reporter/src/testFileView.tsx | 4 +- .../playwright/src/isomorphic/teleReceiver.ts | 16 ++++---- packages/playwright/src/reporters/html.ts | 12 +++--- packages/playwright/src/reporters/merge.ts | 40 ++++++++++--------- .../playwright/src/reporters/teleEmitter.ts | 2 - packages/playwright/src/runner/reporters.ts | 2 +- packages/trace-viewer/src/ui/uiModeView.tsx | 4 +- tests/android/playwright.config.ts | 8 ++-- tests/electron/playwright.config.ts | 4 +- tests/library/playwright.config.ts | 4 +- tests/playwright-test/reporter-blob.spec.ts | 6 +-- 19 files changed, 69 insertions(+), 88 deletions(-) diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 0ad4b68294..8c491514ae 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -55,7 +55,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* - run: node tests/config/checkCoverage.js ${{ matrix.browser }} - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -87,7 +87,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps chromium-tip-of-tree - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium-* env: PWTEST_CHANNEL: chromium-tip-of-tree PWTEST_BOT_NAME: "${{ matrix.os }}-chromium-tip-of-tree" diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 816bf5c3f9..7659c338bc 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -42,7 +42,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* - run: node tests/config/checkCoverage.js ${{ matrix.browser }} - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -75,7 +75,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: npm run test -- --project=${{ matrix.browser }} + - run: npm run test -- --project=${{ matrix.browser }}-* - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() shell: bash @@ -106,10 +106,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: npm run test -- --project=${{ matrix.browser }} --workers=1 + - run: npm run test -- --project=${{ matrix.browser }}-* --workers=1 if: matrix.browser == 'firefox' shell: bash - - run: npm run test -- --project=${{ matrix.browser }} + - run: npm run test -- --project=${{ matrix.browser }}-* if: matrix.browser != 'firefox' shell: bash - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json @@ -175,9 +175,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} --headed + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* --headed if: always() && startsWith(matrix.os, 'ubuntu-') - - run: npm run test -- --project=${{ matrix.browser }} --headed + - run: npm run test -- --project=${{ matrix.browser }}-* --headed if: always() && !startsWith(matrix.os, 'ubuntu-') - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() @@ -247,7 +247,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium ${{ matrix.channel }} - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* env: PWTEST_TRACE: 1 PWTEST_CHANNEL: ${{ matrix.channel }} @@ -868,7 +868,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=chromium-* env: PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW: 1 - run: node tests/config/checkCoverage.js chromium diff --git a/.github/workflows/tests_service.yml b/.github/workflows/tests_service.yml index 9c932f38e4..b8c192f988 100644 --- a/.github/workflows/tests_service.yml +++ b/.github/workflows/tests_service.yml @@ -23,7 +23,7 @@ jobs: env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} --workers=10 --retries=0 + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* --workers=10 --retries=0 env: PWTEST_MODE: service2 PWTEST_TRACE: 1 diff --git a/.github/workflows/tests_video.yml b/.github/workflows/tests_video.yml index f39b544f93..3acda7cec7 100644 --- a/.github/workflows/tests_video.yml +++ b/.github/workflows/tests_video.yml @@ -32,7 +32,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - run: npm run build - run: npx playwright install --with-deps ${{ matrix.browser }} chromium - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }} + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test -- --project=${{ matrix.browser }}-* env: PWTEST_VIDEO: 1 - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json diff --git a/package.json b/package.json index fdbafad27c..253206f667 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ }, "license": "Apache-2.0", "scripts": { - "ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium", - "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox", - "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit", + "ctest": "playwright test --config=tests/library/playwright.config.ts --project=chromium-*", + "ftest": "playwright test --config=tests/library/playwright.config.ts --project=firefox-*", + "wtest": "playwright test --config=tests/library/playwright.config.ts --project=webkit-*", "atest": "playwright test --config=tests/android/playwright.config.ts", "etest": "playwright test --config=tests/electron/playwright.config.ts", "webview2test": "playwright test --config=tests/webview2/playwright.config.ts", diff --git a/packages/html-reporter/src/filter.ts b/packages/html-reporter/src/filter.ts index 6f7ef14525..97cf675936 100644 --- a/packages/html-reporter/src/filter.ts +++ b/packages/html-reporter/src/filter.ts @@ -14,7 +14,6 @@ limitations under the License. */ -import { testCaseLabels } from './labelUtils'; import type { TestCaseSummary } from './types'; export class Filter { @@ -108,13 +107,13 @@ export class Filter { if (test.outcome === 'skipped') status = 'skipped'; const searchValues: SearchValues = { - text: (status + ' ' + test.projectName + ' ' + (test.botName || '') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(), + text: (status + ' ' + test.projectName + ' ' + test.tags.join(' ') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(), project: test.projectName.toLowerCase(), status: status as any, file: test.location.file, line: String(test.location.line), column: String(test.location.column), - labels: testCaseLabels(test).map(label => label.toLowerCase()), + labels: test.tags.map(tag => tag.toLowerCase()), }; (test as any).searchValues = searchValues; } diff --git a/packages/html-reporter/src/labelUtils.tsx b/packages/html-reporter/src/labelUtils.tsx index 57d9c43c9a..014ec77d59 100644 --- a/packages/html-reporter/src/labelUtils.tsx +++ b/packages/html-reporter/src/labelUtils.tsx @@ -14,22 +14,6 @@ * limitations under the License. */ -import type { TestCaseSummary } from './types'; - -const labelsSymbol = Symbol('labels'); - -// Note: all labels start with "@" -export function testCaseLabels(test: TestCaseSummary): string[] { - if (!(test as any)[labelsSymbol]) { - const labels: string[] = []; - if (test.botName) - labels.push('@' + test.botName); - labels.push(...test.tags); - (test as any)[labelsSymbol] = labels; - } - return (test as any)[labelsSymbol]; -} - // hash string to integer in range [0, 6] for color index, to get same color for same tag export function hashStringToInt(str: string) { let hash = 0; diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 4b79908edf..eecbdac9f5 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -23,7 +23,7 @@ import { ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; -import { hashStringToInt, testCaseLabels } from './labelUtils'; +import { hashStringToInt } from './labelUtils'; import { msToString } from './uiUtils'; export const TestCaseView: React.FC<{ @@ -37,7 +37,7 @@ export const TestCaseView: React.FC<{ const labels = React.useMemo(() => { if (!test) return undefined; - return testCaseLabels(test); + return test.tags; }, [test]); return
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 619d6263d2..4f508c42b8 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -23,7 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; -import { hashStringToInt, testCaseLabels } from './labelUtils'; +import { hashStringToInt } from './labelUtils'; export const TestFileView: React.FC {report.projectNames.length > 1 && !!test.projectName && } - +
{msToString(test.duration)} diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index a7f9f412f1..0ba4f4ebd6 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -36,7 +36,6 @@ export type JsonPattern = { }; export type JsonProject = { - id: string; grep: JsonPattern[]; grepInvert: JsonPattern[]; metadata: Metadata; @@ -132,16 +131,18 @@ export class TeleReporterReceiver { private _rootDir!: string; private _listOnly = false; private _clearPreviousResultsWhenTestBegins: boolean = false; - private _reuseTestCases: boolean; + private _mergeTestCases: boolean; + private _mergeProjects: boolean; private _reportConfig: MergeReporterConfig | undefined; private _config!: reporterTypes.FullConfig; private _stringPool = new StringInternPool(); - constructor(pathSeparator: string, reporter: Partial, reuseTestCases: boolean, reportConfig?: MergeReporterConfig) { + constructor(pathSeparator: string, reporter: Partial, mergeProjects: boolean, mergeTestCases: boolean, reportConfig?: MergeReporterConfig) { this._rootSuite = new TeleSuite('', 'root'); this._pathSeparator = pathSeparator; this._reporter = reporter; - this._reuseTestCases = reuseTestCases; + this._mergeProjects = mergeProjects; + this._mergeTestCases = mergeTestCases; this._reportConfig = reportConfig; } @@ -201,7 +202,7 @@ export class TeleReporterReceiver { } private _onProject(project: JsonProject) { - let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.__projectId === project.id); + let projectSuite = this._mergeProjects ? this._rootSuite.suites.find(suite => suite.project()!.name === project.name) : undefined; if (!projectSuite) { projectSuite = new TeleSuite(project.name, 'project'); this._rootSuite.suites.push(projectSuite); @@ -331,7 +332,6 @@ export class TeleReporterReceiver { private _parseProject(project: JsonProject): TeleFullProject { return { - __projectId: project.id, metadata: project.metadata, name: project.name, outputDir: this._absolutePath(project.outputDir), @@ -375,7 +375,7 @@ export class TeleReporterReceiver { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) { for (const jsonTest of jsonTests) { - let targetTest = this._reuseTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; + let targetTest = this._mergeTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; if (!targetTest) { targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location), jsonTest.repeatEachIndex); targetTest.parent = parent; @@ -593,7 +593,7 @@ class TeleTestResult implements reporterTypes.TestResult { } } -export type TeleFullProject = FullProject & { __projectId: string }; +export type TeleFullProject = FullProject; export const baseFullConfig: reporterTypes.FullConfig = { forbidOnly: false, diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 8c0adcd5c0..1b66da35f7 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -241,7 +241,7 @@ class HtmlBuilder { } const { testFile, testFileSummary } = fileEntry; const testEntries: TestEntry[] = []; - this._processJsonSuite(fileSuite, fileId, projectSuite.project()!.name, projectSuite.project()!.metadata?.reportName, [], testEntries); + this._processJsonSuite(fileSuite, fileId, projectSuite.project()!.name, [], testEntries); for (const test of testEntries) { testFile.tests.push(test.testCase); testFileSummary.tests.push(test.testCaseSummary); @@ -341,13 +341,13 @@ class HtmlBuilder { this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName); } - private _processJsonSuite(suite: Suite, fileId: string, projectName: string, botName: string | undefined, path: string[], outTests: TestEntry[]) { + private _processJsonSuite(suite: Suite, fileId: string, projectName: string, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; - suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, botName, newPath, outTests)); - suite.tests.forEach(t => outTests.push(this._createTestEntry(fileId, t, projectName, botName, newPath))); + suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, newPath, outTests)); + suite.tests.forEach(t => outTests.push(this._createTestEntry(fileId, t, projectName, newPath))); } - private _createTestEntry(fileId: string, test: TestCasePublic, projectName: string, botName: string | undefined, path: string[]): TestEntry { + private _createTestEntry(fileId: string, test: TestCasePublic, projectName: string, path: string[]): TestEntry { const duration = test.results.reduce((a, r) => a + r.duration, 0); const location = this._relativeLocation(test.location)!; path = path.slice(1); @@ -363,7 +363,6 @@ class HtmlBuilder { testId, title: test.title, projectName, - botName, location, duration, // Annotations can be pushed directly, with a wrong type. @@ -378,7 +377,6 @@ class HtmlBuilder { testId, title: test.title, projectName, - botName, location, duration, // Annotations can be pushed directly, with a wrong type. diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 096cc66c50..6a38396cbb 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -23,7 +23,7 @@ import { TeleReporterReceiver } from '../isomorphic/teleReceiver'; import { JsonStringInternalizer, StringInternPool } from '../isomorphic/stringInternPool'; import { createReporters } from '../runner/reporters'; import { Multiplexer } from './multiplexer'; -import { ZipFile, calculateSha1 } from 'playwright-core/lib/utils'; +import { ZipFile } from 'playwright-core/lib/utils'; import { currentBlobReportVersion, type BlobReportMetadata } from './blob'; import { relativeFilePath } from '../util'; import type { TestError } from '../../types/testReporter'; @@ -53,7 +53,7 @@ export async function createMergedReport(config: FullConfigInternal, dir: string const eventData = await mergeEvents(dir, shardFiles, stringPool, printStatus, rootDirOverride); // If expicit config is provided, use platform path separator, otherwise use the one from the report (if any). const pathSep = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); - const receiver = new TeleReporterReceiver(pathSep, multiplexer, false, config.config); + const receiver = new TeleReporterReceiver(pathSep, multiplexer, false, false, config.config); printStatus(`processing test events`); const dispatchEvents = async (events: JsonEvent[]) => { @@ -183,22 +183,15 @@ async function mergeEvents(dir: string, shardReportFiles: string[], stringPool: return a.file.localeCompare(b.file); }); - const saltSet = new Set(); - printStatus(`merging events`); const reports: ReportData[] = []; - for (const { file, parsedEvents, metadata, localPath } of blobs) { + for (let i = 0; i < blobs.length; ++i) { // Generate unique salt for each blob. - const sha1 = calculateSha1(metadata.name || path.basename(file)).substring(0, 16); - let salt = sha1; - for (let i = 0; saltSet.has(salt); i++) - salt = sha1 + '-' + i; - saltSet.add(salt); - + const { parsedEvents, metadata, localPath } = blobs[i]; const eventPatchers = new JsonEventPatchers(); - eventPatchers.patchers.push(new IdsPatcher(stringPool, metadata.name, salt)); + eventPatchers.patchers.push(new IdsPatcher(stringPool, metadata.name, String(i))); // Only patch path separators if we are merging reports with explicit config. if (rootDirOverride) eventPatchers.patchers.push(new PathSeparatorPatcher(metadata.pathSeparator)); @@ -354,10 +347,14 @@ class UniqueFileNameGenerator { } class IdsPatcher { - constructor( - private _stringPool: StringInternPool, - private _reportName: string | undefined, - private _salt: string) { + private _stringPool: StringInternPool; + private _botName: string | undefined; + private _salt: string; + + constructor(stringPool: StringInternPool, botName: string | undefined, salt: string) { + this._stringPool = stringPool; + this._botName = botName; + this._salt = salt; } patchEvent(event: JsonEvent) { @@ -380,13 +377,18 @@ class IdsPatcher { private _onProject(project: JsonProject) { project.metadata = project.metadata ?? {}; - project.metadata.reportName = this._reportName; - project.id = this._stringPool.internString(project.id + this._salt); + project.metadata.botName = this._botName; project.suites.forEach(suite => this._updateTestIds(suite)); } private _updateTestIds(suite: JsonSuite) { - suite.tests.forEach(test => test.testId = this._mapTestId(test.testId)); + suite.tests.forEach(test => { + test.testId = this._mapTestId(test.testId); + if (this._botName) { + test.tags = test.tags || []; + test.tags.unshift('@' + this._botName); + } + }); suite.suites.forEach(suite => this._updateTestIds(suite)); } diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index 44a9908300..96178bedf8 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -17,7 +17,6 @@ import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; -import { getProjectId } from '../common/config'; import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; @@ -158,7 +157,6 @@ export class TeleReporterEmitter implements ReporterV2 { private _serializeProject(suite: Suite): JsonProject { const project = suite.project()!; const report: JsonProject = { - id: getProjectId(project), metadata: project.metadata, name: project.name, outputDir: this._relativePath(project.outputDir), diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 033356e1bd..666eb68e40 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -63,7 +63,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | } if (process.env.PW_TEST_REPORTER) { const reporterConstructor = await loadReporter(config, process.env.PW_TEST_REPORTER); - reporters.push(wrapReporterAsV2(new reporterConstructor(runOptions))); + reporters.push(wrapReporterAsV2(new reporterConstructor())); } const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio()); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 89e05f5b48..acdcb141aa 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -654,7 +654,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { lastRunTestCount = suite.allTests().length; lastRunReceiver = undefined; } - }, false); + }, true, false); }, onBegin: (suite: Suite) => { @@ -700,7 +700,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { onExit: () => {}, onStepBegin: () => {}, onStepEnd: () => {}, - }, true); + }, true, true); receiver._setClearPreviousResultsWhenTestBegins(); return sendMessage('list', {}); }; diff --git a/tests/android/playwright.config.ts b/tests/android/playwright.config.ts index 9b41c9b755..0c0c7fbd3a 100644 --- a/tests/android/playwright.config.ts +++ b/tests/android/playwright.config.ts @@ -50,23 +50,23 @@ const metadata = { }; config.projects!.push({ - name: 'android', + name: 'android-native', use: { loopback: '10.0.2.2', browserName: 'chromium', }, - snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}', + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-android{ext}', testDir: path.join(testDir, 'android'), metadata, }); config.projects!.push({ - name: 'android', + name: 'android-page', use: { loopback: '10.0.2.2', browserName: 'chromium', }, - snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}', + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-android{ext}', testDir: path.join(testDir, 'page'), metadata, }); diff --git a/tests/electron/playwright.config.ts b/tests/electron/playwright.config.ts index 59931f33ec..4807a7f919 100644 --- a/tests/electron/playwright.config.ts +++ b/tests/electron/playwright.config.ts @@ -51,7 +51,7 @@ const metadata = { }; config.projects.push({ - name: 'electron', + name: 'electron-api', use: { browserName: 'chromium', coverageName: 'electron', @@ -61,7 +61,7 @@ config.projects.push({ }); config.projects.push({ - name: 'electron', + name: 'electron-page', // Share screenshots with chromium. snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-chromium{ext}', use: { diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index 771b8f2499..568bf6f8aa 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -109,10 +109,10 @@ for (const browserName of browserNames) { const testIgnore: RegExp[] = browserNames.filter(b => b !== browserName).map(b => new RegExp(b)); for (const folder of ['library', 'page']) { config.projects.push({ - name: browserName, + name: `${browserName}-${folder}`, testDir: path.join(testDir, folder), testIgnore, - snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}', + snapshotPathTemplate: `{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-${browserName}{ext}`, use: { mode, browserName, diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index 15c17743e4..6e18651af9 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -1205,9 +1205,9 @@ test('preserve reportName on projects', async ({ runInlineTest, mergeReports }) class EchoReporter { onBegin(config, suite) { - const projects = suite.suites.map(s => s.project()).sort((a, b) => a.metadata.reportName.localeCompare(b.metadata.reportName)); + const projects = suite.suites.map(s => s.project()).sort((a, b) => a.metadata.botName.localeCompare(b.metadata.botName)); console.log('projectNames: ' + projects.map(p => p.name)); - console.log('reportNames: ' + projects.map(p => p.metadata.reportName)); + console.log('botNames: ' + projects.map(p => p.metadata.botName)); } } module.exports = EchoReporter; @@ -1233,7 +1233,7 @@ test('preserve reportName on projects', async ({ runInlineTest, mergeReports }) const { exitCode, output } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', test.info().outputPath('echo-reporter.js')] }); expect(exitCode).toBe(0); expect(output).toContain(`projectNames: foo,foo`); - expect(output).toContain(`reportNames: first,second`); + expect(output).toContain(`botNames: first,second`); }); test('no reports error', async ({ runInlineTest, mergeReports }) => { From 8e48ee714d0093eb6c1c8ee1fe24353320b42a34 Mon Sep 17 00:00:00 2001 From: PaulTriandafilov Date: Sat, 2 Mar 2024 17:27:01 +0200 Subject: [PATCH 045/141] feat(playwright-core): add remove cookies api (#29698) --- docs/src/api/class-browsercontext.md | 21 ++ .../src/client/browserContext.ts | 4 + .../playwright-core/src/client/network.ts | 6 + .../playwright-core/src/protocol/validator.ts | 8 + .../src/server/browserContext.ts | 16 ++ .../dispatchers/browserContextDispatcher.ts | 4 + packages/playwright-core/types/types.d.ts | 22 ++ packages/protocol/src/channels.ts | 12 + packages/protocol/src/protocol.yml | 11 +- .../browsercontext-remove-cookies.spec.ts | 231 ++++++++++++++++++ 10 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/library/browsercontext-remove-cookies.spec.ts diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 87f6369b5c..c47a1892cb 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1010,6 +1010,27 @@ Creates a new page in the browser context. Returns all open pages in the context. +## async method: BrowserContext.removeCookies +* since: v1.43 + +Removes cookies from context. At least one of the removal criteria should be provided. + +**Usage** + +```js +await browserContext.removeCookies({ name: 'session-id' }); +await browserContext.removeCookies({ domain: 'my-origin.com' }); +await browserContext.removeCookies({ path: '/api/v1' }); +await browserContext.removeCookies({ name: 'session-id', domain: 'my-origin.com' }); +``` + +### param: BrowserContext.removeCookies.filter +* since: v1.43 +- `filter` <[Object]> + - `name` ?<[string]> + - `domain` ?<[string]> + - `path` ?<[string]> + ## property: BrowserContext.request * since: v1.16 * langs: diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 39140f904c..37d6b6744f 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -269,6 +269,10 @@ export class BrowserContext extends ChannelOwner await this._channel.clearCookies(); } + async removeCookies(filter: network.RemoveNetworkCookieParam): Promise { + await this._channel.removeCookies({ filter }); + } + async grantPermissions(permissions: string[], options?: { origin?: string }): Promise { await this._channel.grantPermissions({ permissions, ...options }); } diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 6f35fdbc70..75cda8d207 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -58,6 +58,12 @@ export type SetNetworkCookieParam = { sameSite?: 'Strict' | 'Lax' | 'None' }; +export type RemoveNetworkCookieParam = { + name?: string, + domain?: string, + path?: string, +}; + type SerializedFallbackOverrides = { url?: string; method?: string; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f33a3891b6..4a8f7fb3d1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -828,6 +828,14 @@ scheme.BrowserContextAddInitScriptParams = tObject({ scheme.BrowserContextAddInitScriptResult = tOptional(tObject({})); scheme.BrowserContextClearCookiesParams = tOptional(tObject({})); scheme.BrowserContextClearCookiesResult = tOptional(tObject({})); +scheme.BrowserContextRemoveCookiesParams = tObject({ + filter: tObject({ + name: tOptional(tString), + domain: tOptional(tString), + path: tOptional(tString), + }), +}); +scheme.BrowserContextRemoveCookiesResult = tOptional(tObject({})); scheme.BrowserContextClearPermissionsParams = tOptional(tObject({})); scheme.BrowserContextClearPermissionsResult = tOptional(tObject({})); scheme.BrowserContextCloseParams = tObject({ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 0bc14f45e1..51add38e0e 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -276,6 +276,22 @@ export abstract class BrowserContext extends SdkObject { return await this.doGetCookies(urls as string[]); } + async removeCookies(filter: {name?: string, domain?: string, path?: string}): Promise { + if (!filter.name && !filter.domain && !filter.path) + throw new Error(`Either name, domain or path are required`); + + const currentCookies = await this.cookies(); + + const cookiesToKeep = currentCookies.filter(cookie => { + return !((!filter.name || filter.name === cookie.name) && + (!filter.domain || filter.domain === cookie.domain) && + (!filter.path || filter.path === cookie.path)); + }); + + await this.clearCookies(); + await this.addCookies(cookiesToKeep); + } + setHTTPCredentials(httpCredentials?: types.Credentials): Promise { return this.doSetHTTPCredentials(httpCredentials); } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index b4a06a67b5..d04418866a 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -224,6 +224,10 @@ export class BrowserContextDispatcher extends Dispatcher { + await this._context.removeCookies(params.filter); + } + async grantPermissions(params: channels.BrowserContextGrantPermissionsParams): Promise { await this._context.grantPermissions(params.permissions, params.origin); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index e8f1bb9c20..bbebc7dfda 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8441,6 +8441,28 @@ export interface BrowserContext { */ pages(): Array; + /** + * Removes cookies from context. The method will throw an error if either name, domain or path has not been passed. + * + * **Usage** + * + * ```js + * await browserContext.removeCookies({ name: 'session-id' }); + * await browserContext.removeCookies({ domain: 'my-origin.com' }); + * await browserContext.removeCookies({ path: '/api/v1' }); + * await browserContext.removeCookies({ name: 'session-id', domain: 'my-origin.com' }); + * ``` + * + * @param filter + */ + removeCookies(filter: { + name?: string; + + domain?: string; + + path?: string; + }): Promise; + /** * Routing provides the capability to modify network requests that are made by any page in the browser context. Once * route is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or aborted. diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 379b5a48e8..c0af7ac678 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1427,6 +1427,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT addCookies(params: BrowserContextAddCookiesParams, metadata?: CallMetadata): Promise; addInitScript(params: BrowserContextAddInitScriptParams, metadata?: CallMetadata): Promise; clearCookies(params?: BrowserContextClearCookiesParams, metadata?: CallMetadata): Promise; + removeCookies(params: BrowserContextRemoveCookiesParams, metadata?: CallMetadata): Promise; clearPermissions(params?: BrowserContextClearPermissionsParams, metadata?: CallMetadata): Promise; close(params: BrowserContextCloseParams, metadata?: CallMetadata): Promise; cookies(params: BrowserContextCookiesParams, metadata?: CallMetadata): Promise; @@ -1523,6 +1524,17 @@ export type BrowserContextAddInitScriptResult = void; export type BrowserContextClearCookiesParams = {}; export type BrowserContextClearCookiesOptions = {}; export type BrowserContextClearCookiesResult = void; +export type BrowserContextRemoveCookiesParams = { + filter: { + name?: string, + domain?: string, + path?: string, + }, +}; +export type BrowserContextRemoveCookiesOptions = { + +}; +export type BrowserContextRemoveCookiesResult = void; export type BrowserContextClearPermissionsParams = {}; export type BrowserContextClearPermissionsOptions = {}; export type BrowserContextClearPermissionsResult = void; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c21cd006e9..acb847b08b 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1032,6 +1032,15 @@ BrowserContext: clearCookies: + removeCookies: + parameters: + filter: + type: object + properties: + name: string? + domain: string? + path: string? + clearPermissions: close: @@ -3222,7 +3231,7 @@ ElectronApplication: events: close: console: - parameters: + parameters: $mixin: ConsoleMessage Android: diff --git a/tests/library/browsercontext-remove-cookies.spec.ts b/tests/library/browsercontext-remove-cookies.spec.ts new file mode 100644 index 0000000000..e7a3cdaad3 --- /dev/null +++ b/tests/library/browsercontext-remove-cookies.spec.ts @@ -0,0 +1,231 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { contextTest as it, expect } from '../config/browserTest'; + +it('should remove cookies by name', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.com', + path: '/', + } + ]); + await page.goto('https://www.example.com'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + await context.removeCookies({ name: 'cookie1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); +}); + +it('should remove cookies by domain', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.org', + path: '/', + } + ]); + await page.goto('https://www.example.com'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await page.goto('https://www.example.org'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await context.removeCookies({ domain: 'www.example.org' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto('https://www.example.com'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should remove cookies by path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.com', + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: 'www.example.com', + path: '/', + } + ]); + await page.goto('https://www.example.com/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await context.removeCookies({ path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); + await page.goto('https://www.example.com/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2; cookie3=3'); + await page.goto('https://www.example.com/'); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); +}); + +it('should remove cookies by name and domain', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/', + }, + { + name: 'cookie1', + value: '1', + domain: 'www.example.org', + path: '/', + } + ]); + await page.goto('https://www.example.com'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ name: 'cookie1', domain: 'www.example.com' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto('https://www.example.org'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should remove cookies by name and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/api/v1', + }, + { + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: 'www.example.com', + path: '/', + } + ]); + await page.goto('https://www.example.com/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await context.removeCookies({ name: 'cookie1', path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); + await page.goto('https://www.example.com/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); + await page.goto('https://www.example.com/'); + expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); +}); + +it('should remove cookies by domain and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.com', + path: '/api/v2', + }, + { + name: 'cookie3', + value: '3', + domain: 'www.example.org', + path: '/api/v1', + }, + { + name: 'cookie4', + value: '4', + domain: 'www.example.org', + path: '/api/v2', + } + ]); + await page.goto('https://www.example.com/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ domain: 'www.example.com', path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto('https://www.example.com/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await page.goto('https://www.example.org/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie4=4'); +}); + +it('should remove cookies by name, domain and path', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/api/v1', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.com', + path: '/api/v2', + }, + { + name: 'cookie1', + value: '1', + domain: 'www.example.org', + path: '/api/v1', + }, + ]); + await page.goto('https://www.example.com/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + await context.removeCookies({ name: 'cookie1', domain: 'www.example.com', path: '/api/v1' }); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto('https://www.example.com/api/v2'); + expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); + await page.goto('https://www.example.org/api/v1'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); +}); + +it('should throw if empty object is passed', async ({ context, page, server }) => { + await context.addCookies([{ + name: 'cookie1', + value: '1', + domain: 'www.example.com', + path: '/', + }, + { + name: 'cookie2', + value: '2', + domain: 'www.example.com', + path: '/', + }, + ]); + await page.goto('https://www.example.com/'); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + const error = await context.removeCookies({ }).catch(e => e); + expect(error.message).toContain(`Either name, domain or path are required`); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); +}); From e83b4c25beb310459bad84680925a279749af077 Mon Sep 17 00:00:00 2001 From: Jan Sepke <625043+jansepke@users.noreply.github.com> Date: Sat, 2 Mar 2024 16:28:45 +0100 Subject: [PATCH 046/141] docs(test-snapshots-js.md): Add link to snapshotPathTemplate (#29727) ] --- docs/src/test-snapshots-js.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/test-snapshots-js.md b/docs/src/test-snapshots-js.md index a6e5b98636..d2c7606adf 100644 --- a/docs/src/test-snapshots-js.md +++ b/docs/src/test-snapshots-js.md @@ -44,6 +44,8 @@ The snapshot name `example-test-1-chromium-darwin.png` consists of a few parts: - `chromium-darwin` - the browser name and the platform. Screenshots differ between browsers and platforms due to different rendering, fonts and more, so you will need different snapshots for them. If you use multiple projects in your [configuration file](./test-configuration.md), project name will be used instead of `chromium`. +The snapshot name and path can be configured with [`snapshotPathTemplate`](./api/class-testproject#test-project-snapshot-path-template) in the playwright config. + ## Updating screenshots Sometimes you need to update the reference screenshot, for example when the page has changed. Do this with the `--update-snapshots` flag. From 04e17470386b0fc76440ab3379a00cc910dade91 Mon Sep 17 00:00:00 2001 From: Pavel Date: Sat, 2 Mar 2024 07:32:44 -0800 Subject: [PATCH 047/141] chore: follow up to align the tsdoc --- packages/playwright-core/types/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index bbebc7dfda..f8a1dd584e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8442,7 +8442,7 @@ export interface BrowserContext { pages(): Array; /** - * Removes cookies from context. The method will throw an error if either name, domain or path has not been passed. + * Removes cookies from context. At least one of the removal criteria should be provided. * * **Usage** * From d5d4f591f35970f48ffe82bfc5c9e117660d7f0f Mon Sep 17 00:00:00 2001 From: Marco D'Agostini Date: Sun, 3 Mar 2024 02:00:16 +0100 Subject: [PATCH 048/141] fix typo in docs: "toHaveScreeshot()" (#29780) --- docs/src/test-configuration-js.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index ea598c8c8a..99fd492816 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -147,6 +147,6 @@ export default defineConfig({ | Option | Description | | :- | :- | | [`property: TestConfig.expect`] | [Web first assertions](./test-assertions.md) like `expect(locator).toHaveText()` have a separate timeout of 5 seconds by default. This is the maximum time the `expect()` should wait for the condition to be met. Learn more about [test and expect timeouts](./test-timeouts.md) and how to set them for a single test. | -| [`method: PageAssertions.toHaveScreenshot#1`] | Configuration for the `expect(locator).toHaveScreeshot()` method. | +| [`method: PageAssertions.toHaveScreenshot#1`] | Configuration for the `expect(locator).toHaveScreenshot()` method. | | [`method: SnapshotAssertions.toMatchSnapshot#1`]| Configuration for the `expect(locator).toMatchSnapshot()` method.| From 68284b0505bc78cdd118ba381be9c0f17f17070f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 4 Mar 2024 08:46:32 -0800 Subject: [PATCH 049/141] chore: inject string pool into the tele receiver (#29781) --- .../playwright/src/isomorphic/teleReceiver.ts | 45 +++++++------- packages/playwright/src/reporters/merge.ts | 12 +++- .../playwright/src/reporters/teleEmitter.ts | 60 +++++++++---------- packages/trace-viewer/src/ui/uiModeView.tsx | 16 +++-- 4 files changed, 74 insertions(+), 59 deletions(-) diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index 0ba4f4ebd6..1628351a3e 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -18,8 +18,8 @@ import type { Annotation } from '../common/config'; import type { FullProject, Metadata } from '../../types/test'; import type * as reporterTypes from '../../types/testReporter'; import type { ReporterV2 } from '../reporters/reporterV2'; -import { StringInternPool } from './stringInternPool'; +export type StringIntern = (s: string) => string; export type JsonLocation = reporterTypes.Location; export type JsonError = string; export type JsonStackFrame = { file: string, line: number, column: number }; @@ -28,8 +28,6 @@ export type JsonStdIOType = 'stdout' | 'stderr'; export type JsonConfig = Pick; -export type MergeReporterConfig = Pick; - export type JsonPattern = { s?: string; r?: { source: string, flags: string }; @@ -123,27 +121,28 @@ export type JsonEvent = { params: any }; +type TeleReporterReceiverOptions = { + pathSeparator: string; + mergeProjects: boolean; + mergeTestCases: boolean; + internString?: StringIntern; + configOverrides?: Pick; +}; + export class TeleReporterReceiver { private _rootSuite: TeleSuite; - private _pathSeparator: string; + private _options: TeleReporterReceiverOptions; private _reporter: Partial; private _tests = new Map(); private _rootDir!: string; private _listOnly = false; private _clearPreviousResultsWhenTestBegins: boolean = false; - private _mergeTestCases: boolean; - private _mergeProjects: boolean; - private _reportConfig: MergeReporterConfig | undefined; private _config!: reporterTypes.FullConfig; - private _stringPool = new StringInternPool(); - constructor(pathSeparator: string, reporter: Partial, mergeProjects: boolean, mergeTestCases: boolean, reportConfig?: MergeReporterConfig) { + constructor(reporter: Partial, options: TeleReporterReceiverOptions) { this._rootSuite = new TeleSuite('', 'root'); - this._pathSeparator = pathSeparator; + this._options = options; this._reporter = reporter; - this._mergeProjects = mergeProjects; - this._mergeTestCases = mergeTestCases; - this._reportConfig = reportConfig; } dispatch(mode: 'list' | 'test', message: JsonEvent): Promise | void { @@ -202,7 +201,7 @@ export class TeleReporterReceiver { } private _onProject(project: JsonProject) { - let projectSuite = this._mergeProjects ? this._rootSuite.suites.find(suite => suite.project()!.name === project.name) : undefined; + let projectSuite = this._options.mergeProjects ? this._rootSuite.suites.find(suite => suite.project()!.name === project.name) : undefined; if (!projectSuite) { projectSuite = new TeleSuite(project.name, 'project'); this._rootSuite.suites.push(projectSuite); @@ -315,17 +314,16 @@ export class TeleReporterReceiver { private _onExit(): Promise | void { // Free up the memory from the string pool. - this._stringPool = new StringInternPool(); return this._reporter.onExit?.(); } private _parseConfig(config: JsonConfig): reporterTypes.FullConfig { const result = { ...baseFullConfig, ...config }; - if (this._reportConfig) { - result.configFile = this._reportConfig.configFile; - result.reportSlowTests = this._reportConfig.reportSlowTests; - result.quiet = this._reportConfig.quiet; - result.reporter = [...this._reportConfig.reporter]; + if (this._options.configOverrides) { + result.configFile = this._options.configOverrides.configFile; + result.reportSlowTests = this._options.configOverrides.reportSlowTests; + result.quiet = this._options.configOverrides.quiet; + result.reporter = [...this._options.configOverrides.reporter]; } return result; } @@ -375,7 +373,7 @@ export class TeleReporterReceiver { private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) { for (const jsonTest of jsonTests) { - let targetTest = this._mergeTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; + let targetTest = this._options.mergeTestCases ? parent.tests.find(s => s.title === jsonTest.title && s.repeatEachIndex === jsonTest.repeatEachIndex) : undefined; if (!targetTest) { targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, this._absoluteLocation(jsonTest.location), jsonTest.repeatEachIndex); targetTest.parent = parent; @@ -410,9 +408,12 @@ export class TeleReporterReceiver { private _absolutePath(relativePath?: string): string | undefined { if (relativePath === undefined) return; - return this._stringPool.internString(this._rootDir + this._pathSeparator + relativePath); + return this._internString(this._rootDir + this._options.pathSeparator + relativePath); } + private _internString(s: string): string { + return this._options.internString ? this._options.internString(s) : s; + } } export class TeleSuite { diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 6a38396cbb..4e4d02d697 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -51,9 +51,15 @@ export async function createMergedReport(config: FullConfigInternal, dir: string if (shardFiles.length === 0) throw new Error(`No report files found in ${dir}`); const eventData = await mergeEvents(dir, shardFiles, stringPool, printStatus, rootDirOverride); - // If expicit config is provided, use platform path separator, otherwise use the one from the report (if any). - const pathSep = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); - const receiver = new TeleReporterReceiver(pathSep, multiplexer, false, false, config.config); + // If explicit config is provided, use platform path separator, otherwise use the one from the report (if any). + const pathSeparator = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); + const receiver = new TeleReporterReceiver(multiplexer, { + pathSeparator, + mergeProjects: false, + mergeTestCases: false, + internString: s => stringPool.internString(s), + configOverrides: config.config, + }); printStatus(`processing test events`); const dispatchEvents = async (events: JsonEvent[]) => { diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index 96178bedf8..e06963663e 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -16,17 +16,17 @@ import path from 'path'; import { createGuid } from 'playwright-core/lib/utils'; -import type { FullConfig, FullResult, Location, Suite, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter'; -import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver'; +import type * as reporterTypes from '../../types/testReporter'; +import type * as teleReceiver from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; export class TeleReporterEmitter implements ReporterV2 { - private _messageSink: (message: JsonEvent) => void; + private _messageSink: (message: teleReceiver.JsonEvent) => void; private _rootDir!: string; private _skipBuffers: boolean; - constructor(messageSink: (message: JsonEvent) => void, skipBuffers: boolean) { + constructor(messageSink: (message: teleReceiver.JsonEvent) => void, skipBuffers: boolean) { this._messageSink = messageSink; this._skipBuffers = skipBuffers; } @@ -35,19 +35,19 @@ export class TeleReporterEmitter implements ReporterV2 { return 'v2'; } - onConfigure(config: FullConfig) { + onConfigure(config: reporterTypes.FullConfig) { this._rootDir = config.rootDir; this._messageSink({ method: 'onConfigure', params: { config: this._serializeConfig(config) } }); } - onBegin(suite: Suite) { + onBegin(suite: reporterTypes.Suite) { const projects = suite.suites.map(projectSuite => this._serializeProject(projectSuite)); for (const project of projects) this._messageSink({ method: 'onProject', params: { project } }); this._messageSink({ method: 'onBegin', params: undefined }); } - onTestBegin(test: TestCase, result: TestResult): void { + onTestBegin(test: reporterTypes.TestCase, result: reporterTypes.TestResult): void { (result as any)[idSymbol] = createGuid(); this._messageSink({ method: 'onTestBegin', @@ -58,8 +58,8 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onTestEnd(test: TestCase, result: TestResult): void { - const testEnd: JsonTestEnd = { + onTestEnd(test: reporterTypes.TestCase, result: reporterTypes.TestResult): void { + const testEnd: teleReceiver.JsonTestEnd = { testId: test.id, expectedStatus: test.expectedStatus, annotations: test.annotations, @@ -74,7 +74,7 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onStepBegin(test: TestCase, result: TestResult, step: TestStep): void { + onStepBegin(test: reporterTypes.TestCase, result: reporterTypes.TestResult, step: reporterTypes.TestStep): void { (step as any)[idSymbol] = createGuid(); this._messageSink({ method: 'onStepBegin', @@ -86,7 +86,7 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onStepEnd(test: TestCase, result: TestResult, step: TestStep): void { + onStepEnd(test: reporterTypes.TestCase, result: reporterTypes.TestResult, step: reporterTypes.TestStep): void { this._messageSink({ method: 'onStepEnd', params: { @@ -97,22 +97,22 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - onError(error: TestError): void { + onError(error: reporterTypes.TestError): void { this._messageSink({ method: 'onError', params: { error } }); } - onStdOut(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + onStdOut(chunk: string | Buffer, test?: reporterTypes.TestCase, result?: reporterTypes.TestResult): void { this._onStdIO('stdout', chunk, test, result); } - onStdErr(chunk: string | Buffer, test?: TestCase, result?: TestResult): void { + onStdErr(chunk: string | Buffer, test?: reporterTypes.TestCase, result?: reporterTypes.TestResult): void { this._onStdIO('stderr', chunk, test, result); } - private _onStdIO(type: JsonStdIOType, chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { + private _onStdIO(type: teleReceiver.JsonStdIOType, chunk: string | Buffer, test: void | reporterTypes.TestCase, result: void | reporterTypes.TestResult): void { const isBase64 = typeof chunk !== 'string'; const data = isBase64 ? chunk.toString('base64') : chunk; this._messageSink({ @@ -121,8 +121,8 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - async onEnd(result: FullResult) { - const resultPayload: JsonFullResult = { + async onEnd(result: reporterTypes.FullResult) { + const resultPayload: teleReceiver.JsonFullResult = { status: result.status, startTime: result.startTime.getTime(), duration: result.duration, @@ -142,7 +142,7 @@ export class TeleReporterEmitter implements ReporterV2 { return false; } - private _serializeConfig(config: FullConfig): JsonConfig { + private _serializeConfig(config: reporterTypes.FullConfig): teleReceiver.JsonConfig { return { configFile: this._relativePath(config.configFile), globalTimeout: config.globalTimeout, @@ -154,9 +154,9 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeProject(suite: Suite): JsonProject { + private _serializeProject(suite: reporterTypes.Suite): teleReceiver.JsonProject { const project = suite.project()!; - const report: JsonProject = { + const report: teleReceiver.JsonProject = { metadata: project.metadata, name: project.name, outputDir: this._relativePath(project.outputDir), @@ -178,7 +178,7 @@ export class TeleReporterEmitter implements ReporterV2 { return report; } - private _serializeSuite(suite: Suite): JsonSuite { + private _serializeSuite(suite: reporterTypes.Suite): teleReceiver.JsonSuite { const result = { title: suite.title, location: this._relativeLocation(suite.location), @@ -188,7 +188,7 @@ export class TeleReporterEmitter implements ReporterV2 { return result; } - private _serializeTest(test: TestCase): JsonTestCase { + private _serializeTest(test: reporterTypes.TestCase): teleReceiver.JsonTestCase { return { testId: test.id, title: test.title, @@ -199,7 +199,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeResultStart(result: TestResult): JsonTestResultStart { + private _serializeResultStart(result: reporterTypes.TestResult): teleReceiver.JsonTestResultStart { return { id: (result as any)[idSymbol], retry: result.retry, @@ -209,7 +209,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeResultEnd(result: TestResult): JsonTestResultEnd { + private _serializeResultEnd(result: reporterTypes.TestResult): teleReceiver.JsonTestResultEnd { return { id: (result as any)[idSymbol], duration: result.duration, @@ -219,7 +219,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - _serializeAttachments(attachments: TestResult['attachments']): JsonAttachment[] { + _serializeAttachments(attachments: reporterTypes.TestResult['attachments']): teleReceiver.JsonAttachment[] { return attachments.map(a => { return { ...a, @@ -229,7 +229,7 @@ export class TeleReporterEmitter implements ReporterV2 { }); } - private _serializeStepStart(step: TestStep): JsonTestStepStart { + private _serializeStepStart(step: reporterTypes.TestStep): teleReceiver.JsonTestStepStart { return { id: (step as any)[idSymbol], parentStepId: (step.parent as any)?.[idSymbol], @@ -240,7 +240,7 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeStepEnd(step: TestStep): JsonTestStepEnd { + private _serializeStepEnd(step: reporterTypes.TestStep): teleReceiver.JsonTestStepEnd { return { id: (step as any)[idSymbol], duration: step.duration, @@ -248,9 +248,9 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _relativeLocation(location: Location): Location; - private _relativeLocation(location?: Location): Location | undefined; - private _relativeLocation(location: Location | undefined): Location | undefined { + private _relativeLocation(location: reporterTypes.Location): reporterTypes.Location; + private _relativeLocation(location?: reporterTypes.Location): reporterTypes.Location | undefined; + private _relativeLocation(location: reporterTypes.Location | undefined): reporterTypes.Location | undefined { if (!location) return location; return { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index acdcb141aa..945294fa94 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -640,7 +640,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { skipped: 0, }; let config: FullConfig; - receiver = new TeleReporterReceiver(pathSeparator, { + receiver = new TeleReporterReceiver({ version: () => 'v2', onConfigure: (c: FullConfig) => { @@ -649,12 +649,16 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { // run one test, we still get many tests via rootSuite.allTests().length. // To work around that, have a dedicated per-run receiver that will only have // suite for a single test run, and hence will have correct total. - lastRunReceiver = new TeleReporterReceiver(pathSeparator, { + lastRunReceiver = new TeleReporterReceiver({ onBegin: (suite: Suite) => { lastRunTestCount = suite.allTests().length; lastRunReceiver = undefined; } - }, true, false); + }, { + pathSeparator, + mergeProjects: true, + mergeTestCases: false + }); }, onBegin: (suite: Suite) => { @@ -700,7 +704,11 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { onExit: () => {}, onStepBegin: () => {}, onStepEnd: () => {}, - }, true, true); + }, { + pathSeparator, + mergeProjects: true, + mergeTestCases: true, + }); receiver._setClearPreviousResultsWhenTestBegins(); return sendMessage('list', {}); }; From a431afb818dccc9c369dad661e2ef60d42c8ada0 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:11:04 -0800 Subject: [PATCH 050/141] feat(webkit): roll to r1986 (#29800) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index b5c590e384..06521e4f20 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "1985", + "revision": "1986", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From de73af99fa8f18cf85842398373fc5c55ba2133d Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:11:17 -0800 Subject: [PATCH 051/141] feat(firefox): roll to r1443 (#29801) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 06521e4f20..6927c9aa48 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -21,13 +21,13 @@ }, { "name": "firefox", - "revision": "1442", + "revision": "1443", "installByDefault": true, "browserVersion": "123.0" }, { "name": "firefox-asan", - "revision": "1442", + "revision": "1443", "installByDefault": false, "browserVersion": "123.0" }, From 743a6ffe1d5dc354e7a5a21f947ec34911409499 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 4 Mar 2024 11:08:40 -0800 Subject: [PATCH 052/141] chore: iterate towards tele reporter reuse in vscode (#29803) --- .../playwright/src/isomorphic/teleReceiver.ts | 3 +- packages/playwright/src/reporters/html.ts | 2 +- packages/playwright/src/runner/loadUtils.ts | 2 +- packages/playwright/src/runner/reporters.ts | 21 ++++++++++-- packages/playwright/src/runner/runner.ts | 2 +- packages/playwright/src/runner/testServer.ts | 32 +++++++++---------- .../src/runner/testServerInterface.ts | 4 +-- 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index 1628351a3e..8b64ce747a 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -313,7 +313,6 @@ export class TeleReporterReceiver { } private _onExit(): Promise | void { - // Free up the memory from the string pool. return this._reporter.onExit?.(); } @@ -416,7 +415,7 @@ export class TeleReporterReceiver { } } -export class TeleSuite { +export class TeleSuite implements reporterTypes.Suite { title: string; location?: reporterTypes.Location; parent?: TeleSuite; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 1b66da35f7..e0262122d1 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -128,7 +128,7 @@ class HtmlReporter extends EmptyReporter { const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure'); if (shouldOpen) { await showHTMLReport(this._outputFolder, this._options.host, this._options.port, singleTestId); - } else if (this._options._mode === 'run') { + } else if (this._options._mode === 'test') { const packageManagerCommand = getPackageManagerExecCommand(); const relativeReportPath = this._outputFolder === standaloneDefaultFolder() ? '' : ' ' + path.relative(process.cwd(), this._outputFolder); const hostArg = this._options.host ? ` --host ${this._options.host}` : ''; diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index f6ecccb3d6..4b881264d2 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -316,7 +316,7 @@ export function loadGlobalHook(config: FullConfigInternal, file: string): Promis return requireOrImportDefaultFunction(path.resolve(config.config.rootDir, file), false); } -export function loadReporter(config: FullConfigInternal | undefined, file: string): Promise Reporter> { +export function loadReporter(config: FullConfigInternal, file: string): Promise Reporter> { return requireOrImportDefaultFunction(config ? path.resolve(config.config.rootDir, file) : file, true); } diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 666eb68e40..3ec842f7e7 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -33,7 +33,7 @@ import { BlobReporter } from '../reporters/blob'; import type { ReporterDescription } from '../../types/test'; import { type ReporterV2, wrapReporterAsV2 } from '../reporters/reporterV2'; -export async function createReporters(config: FullConfigInternal, mode: 'list' | 'run' | 'ui' | 'merge', descriptions?: ReporterDescription[]): Promise { +export async function createReporters(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', descriptions?: ReporterDescription[]): Promise { const defaultReporters: { [key in BuiltInReporter]: new(arg: any) => ReporterV2 } = { blob: BlobReporter, dot: mode === 'list' ? ListModeReporter : DotReporter, @@ -50,7 +50,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | descriptions ??= config.config.reporter; if (config.configCLIOverrides.additionalReporters) descriptions = [...descriptions, ...config.configCLIOverrides.additionalReporters]; - const runOptions = { configDir: config.configDir, _mode: mode }; + const runOptions = reporterOptions(config, mode); for (const r of descriptions) { const [name, arg] = r; const options = { ...runOptions, ...arg }; @@ -63,7 +63,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | } if (process.env.PW_TEST_REPORTER) { const reporterConstructor = await loadReporter(config, process.env.PW_TEST_REPORTER); - reporters.push(wrapReporterAsV2(new reporterConstructor())); + reporters.push(wrapReporterAsV2(new reporterConstructor(runOptions))); } const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio()); @@ -78,6 +78,21 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | return reporters; } +export async function createReporterForTestServer(config: FullConfigInternal, file: string, mode: 'test' | 'list', messageSink: (message: any) => void): Promise { + const reporterConstructor = await loadReporter(config, file); + const runOptions = reporterOptions(config, mode, messageSink); + const instance = new reporterConstructor(runOptions); + return wrapReporterAsV2(instance); +} + +function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', send?: (message: any) => void) { + return { + configDir: config.configDir, + send, + _mode: mode, + }; +} + class ListModeReporter extends EmptyReporter { private config!: FullConfig; diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 142f0958cd..fc6a803c3c 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -83,7 +83,7 @@ export class Runner { // Legacy webServer support. webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'run'))); + const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test'))); const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true }) : createTaskRunner(config, reporter); diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index c21733b9d5..4df7f7fd49 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -23,7 +23,7 @@ import type { FullResult, TestError } from 'playwright/types/testReporter'; import { loadConfig, restartWithExperimentalTsEsm } from '../common/configLoader'; import { InternalReporter } from '../reporters/internalReporter'; import { Multiplexer } from '../reporters/multiplexer'; -import { createReporters } from './reporters'; +import { createReporterForTestServer, createReporters } from './reporters'; import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer } from './tasks'; import type { ConfigCLIOverrides } from '../common/ipc'; import { Runner } from './runner'; @@ -32,8 +32,6 @@ import type { FullConfigInternal } from '../common/config'; import type { TestServerInterface } from './testServerInterface'; import { serializeError } from '../util'; import { prepareErrorStack } from '../reporters/base'; -import { loadReporter } from './loadUtils'; -import { wrapReporterAsV2 } from '../reporters/reporterV2'; export async function runTestServer() { if (restartWithExperimentalTsEsm(undefined, true)) @@ -116,13 +114,13 @@ class Dispatcher implements TestServerInterface { async listTests(params: { configFile: string; locations: string[]; - reporter: string; + reporters: { file: string, event: string }[]; env: NodeJS.ProcessEnv; }) { const config = await this._loadConfig(params.configFile); config.cliArgs = params.locations || []; - const wireReporter = await this._createReporter(params.reporter); - const reporter = new InternalReporter(new Multiplexer([wireReporter])); + const wireReporters = await this._wireReporters(config, 'list', params.reporters); + const reporter = new InternalReporter(new Multiplexer(wireReporters)); const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: true }); const testRun = new TestRun(config, reporter); reporter.onConfigure(config.config); @@ -140,7 +138,7 @@ class Dispatcher implements TestServerInterface { async test(params: { configFile: string; locations: string[]; - reporter: string; + reporters: { file: string, event: string }[]; env: NodeJS.ProcessEnv; headed?: boolean; oneWorker?: boolean; @@ -171,9 +169,9 @@ class Dispatcher implements TestServerInterface { config.cliGrep = params.grep; config.cliProjectFilter = params.projects?.length ? params.projects : undefined; - const wireReporter = await this._createReporter(params.reporter); - const configReporters = await createReporters(config, 'run'); - const reporter = new InternalReporter(new Multiplexer([...configReporters, wireReporter])); + const wireReporters = await this._wireReporters(config, 'test', params.reporters); + const configReporters = await createReporters(config, 'test'); + const reporter = new InternalReporter(new Multiplexer([...configReporters, ...wireReporters])); const taskRunner = createTaskRunnerForTestServer(config, reporter); const testRun = new TestRun(config, reporter); reporter.onConfigure(config.config); @@ -188,6 +186,14 @@ class Dispatcher implements TestServerInterface { await run; } + private async _wireReporters(config: FullConfigInternal, mode: 'test' | 'list', reporters: { file: string, event: string }[]) { + return await Promise.all(reporters.map(r => { + return createReporterForTestServer(config, r.file, mode, message => { + this._dispatchEvent(r.event, message); + }); + })); + } + async findRelatedTestFiles(params: { configFile: string; files: string[]; @@ -219,12 +225,6 @@ class Dispatcher implements TestServerInterface { private async _loadConfig(configFile: string, overrides?: ConfigCLIOverrides): Promise { return loadConfig({ resolvedConfigFile: configFile, configDir: path.dirname(configFile) }, overrides); } - - private async _createReporter(file: string) { - const reporterConstructor = await loadReporter(undefined, file); - const instance = new reporterConstructor((message: any) => this._dispatchEvent('report', message)); - return wrapReporterAsV2(instance); - } } function chunkToPayload(type: 'stdout' | 'stderr', chunk: Buffer | string) { diff --git a/packages/playwright/src/runner/testServerInterface.ts b/packages/playwright/src/runner/testServerInterface.ts index 1e844d8123..23c404da84 100644 --- a/packages/playwright/src/runner/testServerInterface.ts +++ b/packages/playwright/src/runner/testServerInterface.ts @@ -33,13 +33,13 @@ export interface TestServerInterface { listTests(params: { configFile: string; locations: string[]; - reporter: string; + reporters: { file: string, event: string }[]; }): Promise; test(params: { configFile: string; locations: string[]; - reporter: string; + reporters: { file: string, event: string }[]; headed?: boolean; oneWorker?: boolean; trace?: 'on' | 'off'; From ef0a24a1b0c2a8562ac0de0e081d5e566fa8e038 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 4 Mar 2024 20:12:01 +0100 Subject: [PATCH 053/141] chore: remove chromium-with-symbols build (#29807) --- .../trigger_build_chromium_with_symbols.yml | 30 ------------- packages/playwright-core/browsers.json | 6 --- .../src/server/registry/index.ts | 45 +------------------ 3 files changed, 2 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/trigger_build_chromium_with_symbols.yml diff --git a/.github/workflows/trigger_build_chromium_with_symbols.yml b/.github/workflows/trigger_build_chromium_with_symbols.yml deleted file mode 100644 index e0e31acd57..0000000000 --- a/.github/workflows/trigger_build_chromium_with_symbols.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: "Trigger: Chromium with Symbols Builds" - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - trigger: - name: "trigger" - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: 18 - - name: Get Chromium revision - id: chromium-version - run: | - REVISION=$(node -e "console.log(require('./packages/playwright-core/browsers.json').browsers.find(b => b.name === 'chromium-with-symbols').revision)") - echo "REVISION=$REVISION" >> $GITHUB_OUTPUT - - run: | - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${GH_TOKEN}" \ - --data "{\"event_type\": \"build_chromium_with_symbols\", \"client_payload\": {\"revision\": \"${CHROMIUM_REVISION}\"}}" \ - https://api.github.com/repos/microsoft/playwright-browsers/dispatches - env: - GH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_PERSONAL_ACCESS_TOKEN }} - CHROMIUM_REVISION: ${{ steps.chromium-version.outputs.REVISION }} diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6927c9aa48..eb427e8df3 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -7,12 +7,6 @@ "installByDefault": true, "browserVersion": "123.0.6312.22" }, - { - "name": "chromium-with-symbols", - "revision": "1107", - "installByDefault": false, - "browserVersion": "123.0.6312.22" - }, { "name": "chromium-tip-of-tree", "revision": "1198", diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index fa3a1bbf78..c3547c1118 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -121,29 +121,6 @@ const DOWNLOAD_PATHS: Record = { 'mac13-arm64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-mac-arm64.zip', 'win64': 'builds/chromium-tip-of-tree/%s/chromium-tip-of-tree-win64.zip', }, - 'chromium-with-symbols': { - '': undefined, - 'ubuntu18.04-x64': undefined, - 'ubuntu20.04-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'ubuntu22.04-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'ubuntu18.04-arm64': undefined, - 'ubuntu20.04-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'ubuntu22.04-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'debian11-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'debian11-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'debian12-x64': 'builds/chromium/%s/chromium-with-symbols-linux.zip', - 'debian12-arm64': 'builds/chromium/%s/chromium-with-symbols-linux-arm64.zip', - 'mac10.13': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac10.14': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac10.15': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac11': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac11-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'mac12': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac12-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'mac13': 'builds/chromium/%s/chromium-with-symbols-mac.zip', - 'mac13-arm64': 'builds/chromium/%s/chromium-with-symbols-mac-arm64.zip', - 'win64': 'builds/chromium/%s/chromium-with-symbols-win64.zip', - }, 'firefox': { '': undefined, 'ubuntu18.04-x64': undefined, @@ -368,9 +345,9 @@ function readDescriptors(browsersJSON: BrowsersJSON) { } export type BrowserName = 'chromium' | 'firefox' | 'webkit'; -type InternalTool = 'ffmpeg' | 'firefox-beta' | 'firefox-asan' | 'chromium-with-symbols' | 'chromium-tip-of-tree' | 'android'; +type InternalTool = 'ffmpeg' | 'firefox-beta' | 'firefox-asan' | 'chromium-tip-of-tree' | 'android'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; -const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-with-symbols', 'chromium-tip-of-tree']; +const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree']; export interface Executable { type: 'browser' | 'tool' | 'channel'; @@ -453,24 +430,6 @@ export class Registry { _isHermeticInstallation: true, }); - const chromiumWithSymbols = descriptors.find(d => d.name === 'chromium-with-symbols')!; - const chromiumWithSymbolsExecutable = findExecutablePath(chromiumWithSymbols.dir, 'chromium'); - this._executables.push({ - type: 'tool', - name: 'chromium-with-symbols', - browserName: 'chromium', - directory: chromiumWithSymbols.dir, - executablePath: () => chromiumWithSymbolsExecutable, - executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium-with-symbols', chromiumWithSymbolsExecutable, chromiumWithSymbols.installByDefault, sdkLanguage), - installType: chromiumWithSymbols.installByDefault ? 'download-by-default' : 'download-on-demand', - _validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromiumWithSymbols.dir, ['chrome-linux'], [], ['chrome-win']), - downloadURLs: this._downloadURLs(chromiumWithSymbols), - browserVersion: chromiumWithSymbols.browserVersion, - _install: () => this._downloadExecutable(chromiumWithSymbols, chromiumWithSymbolsExecutable), - _dependencyGroup: 'chromium', - _isHermeticInstallation: true, - }); - const chromiumTipOfTree = descriptors.find(d => d.name === 'chromium-tip-of-tree')!; const chromiumTipOfTreeExecutable = findExecutablePath(chromiumTipOfTree.dir, 'chromium'); this._executables.push({ From 73ffaf65d75b2378168ac5a11eb37cced03ff6ea Mon Sep 17 00:00:00 2001 From: Rui Figueira Date: Mon, 4 Mar 2024 20:31:03 +0000 Subject: [PATCH 054/141] fix(codegen): fill action prevents omnibox navigation recording (#29790) This PR is a fix proposal for a bug when trying to record a omnibox navigation after a recorded action (e.g., `fill`). The following test, included in this PR, reproduces the problem: ```ts test('should record omnibox navigations after recordAction', async ({ page, openRecorder, server }) => { const recorder = await openRecorder(); await recorder.setContentAndWait(``); await Promise.all([ recorder.waitForOutput('JavaScript', 'fill'), page.locator('textarea').fill('Hello world'), ]); // for performed actions, 5 seconds is the time needed to ensure they are committed await page.waitForTimeout(5000); await page.goto(server.PREFIX + `/empty.html`); await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/empty.html');`); }); ``` After performed actions (e.g., `click`), it successfully records the navigation as long as there's at least a 5 sec. gap between both actions. That happens because after that 5 sec. interval the performed action is automatically commited and therefore the navigation is not stored as a signal of that action. The proposed fix for recorded actions also forces that action to be automatically commited after 5 sec (for testing, I'm using 500ms to speed up the test execution). --- .../playwright-core/src/server/recorder.ts | 17 ++++++++----- tests/library/inspector/cli-codegen-1.spec.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 997e1244d2..d5f84ba625 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -626,13 +626,8 @@ class ContextRecorder extends EventEmitter { callMetadata.endTime = monotonicTime(); await frame.instrumentation.onAfterCall(frame, callMetadata); - const timer = setTimeout(() => { - // Commit the action after 5 seconds so that no further signals are added to it. - actionInContext.committed = true; - this._timers.delete(timer); - }, 5000); + this._setCommittedAfterTimeout(actionInContext); this._generator.didPerformAction(actionInContext); - this._timers.add(timer); }; const kActionTimeout = 5000; @@ -664,9 +659,19 @@ class ContextRecorder extends EventEmitter { frame: frameDescription, action }; + this._setCommittedAfterTimeout(actionInContext); this._generator.addAction(actionInContext); } + private _setCommittedAfterTimeout(actionInContext: ActionInContext) { + const timer = setTimeout(() => { + // Commit the action after 5 seconds so that no further signals are added to it. + actionInContext.committed = true; + this._timers.delete(timer); + }, isUnderTest() ? 500 : 5000); + this._timers.add(timer); + } + private _onFrameNavigated(frame: Frame, page: Page) { const pageAlias = this._pageAliases.get(page); this._generator.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() }); diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index a0c5596eea..a54ae4d421 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -817,4 +817,28 @@ await page.GetByRole(AriaRole.Slider).FillAsync("10");`); expect.soft(sources.get('C#')!.text).toContain(` await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();`); }); + + test('should record omnibox navigations after performAction', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(``); + await Promise.all([ + recorder.waitForOutput('JavaScript', 'click'), + page.locator('button').click(), + ]); + await page.waitForTimeout(500); + await page.goto(server.PREFIX + `/empty.html`); + await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/empty.html');`); + }); + + test('should record omnibox navigations after recordAction', async ({ page, openRecorder, server }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(``); + await Promise.all([ + recorder.waitForOutput('JavaScript', 'fill'), + page.locator('textarea').fill('Hello world'), + ]); + await page.waitForTimeout(500); + await page.goto(server.PREFIX + `/empty.html`); + await recorder.waitForOutput('JavaScript', `await page.goto('${server.PREFIX}/empty.html');`); + }); }); From 291567b922ff9afe8357103e005292a8464bbc85 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 4 Mar 2024 23:49:12 +0100 Subject: [PATCH 055/141] test(remove-cookie): do not rely on external websites for tests (#29811) Fixes https://github.com/microsoft/playwright/issues/29795 --- .../browsercontext-remove-cookies.spec.ts | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/library/browsercontext-remove-cookies.spec.ts b/tests/library/browsercontext-remove-cookies.spec.ts index e7a3cdaad3..f1524e6ef1 100644 --- a/tests/library/browsercontext-remove-cookies.spec.ts +++ b/tests/library/browsercontext-remove-cookies.spec.ts @@ -21,17 +21,17 @@ it('should remove cookies by name', async ({ context, page, server }) => { await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', }, { name: 'cookie2', value: '2', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', } ]); - await page.goto('https://www.example.com'); + await page.goto(server.PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); await context.removeCookies({ name: 'cookie1' }); expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); @@ -41,23 +41,23 @@ it('should remove cookies by domain', async ({ context, page, server }) => { await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', }, { name: 'cookie2', value: '2', - domain: 'www.example.org', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, path: '/', } ]); - await page.goto('https://www.example.com'); + await page.goto(server.PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); - await page.goto('https://www.example.org'); + await page.goto(server.CROSS_PROCESS_PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); - await context.removeCookies({ domain: 'www.example.org' }); + await context.removeCookies({ domain: new URL(server.CROSS_PROCESS_PREFIX).hostname }); expect(await page.evaluate('document.cookie')).toBe(''); - await page.goto('https://www.example.com'); + await page.goto(server.PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); }); @@ -65,29 +65,29 @@ it('should remove cookies by path', async ({ context, page, server }) => { await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v1', }, { name: 'cookie2', value: '2', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v2', }, { name: 'cookie3', value: '3', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', } ]); - await page.goto('https://www.example.com/api/v1'); + await page.goto(server.PREFIX + '/api/v1'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); await context.removeCookies({ path: '/api/v1' }); expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); - await page.goto('https://www.example.com/api/v2'); + await page.goto(server.PREFIX + '/api/v2'); expect(await page.evaluate('document.cookie')).toBe('cookie2=2; cookie3=3'); - await page.goto('https://www.example.com/'); + await page.goto(server.PREFIX + '/'); expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); }); @@ -95,21 +95,21 @@ it('should remove cookies by name and domain', async ({ context, page, server }) await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', }, { name: 'cookie1', value: '1', - domain: 'www.example.org', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, path: '/', } ]); - await page.goto('https://www.example.com'); + await page.goto(server.PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); - await context.removeCookies({ name: 'cookie1', domain: 'www.example.com' }); + await context.removeCookies({ name: 'cookie1', domain: new URL(server.PREFIX).hostname }); expect(await page.evaluate('document.cookie')).toBe(''); - await page.goto('https://www.example.org'); + await page.goto(server.CROSS_PROCESS_PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); }); @@ -117,29 +117,29 @@ it('should remove cookies by name and path', async ({ context, page, server }) = await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v1', }, { name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v2', }, { name: 'cookie3', value: '3', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', } ]); - await page.goto('https://www.example.com/api/v1'); + await page.goto(server.PREFIX + '/api/v1'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); await context.removeCookies({ name: 'cookie1', path: '/api/v1' }); expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); - await page.goto('https://www.example.com/api/v2'); + await page.goto(server.PREFIX + '/api/v2'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie3=3'); - await page.goto('https://www.example.com/'); + await page.goto(server.PREFIX + '/'); expect(await page.evaluate('document.cookie')).toBe('cookie3=3'); }); @@ -147,35 +147,35 @@ it('should remove cookies by domain and path', async ({ context, page, server }) await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v1', }, { name: 'cookie2', value: '2', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v2', }, { name: 'cookie3', value: '3', - domain: 'www.example.org', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, path: '/api/v1', }, { name: 'cookie4', value: '4', - domain: 'www.example.org', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, path: '/api/v2', } ]); - await page.goto('https://www.example.com/api/v1'); + await page.goto(server.PREFIX + '/api/v1'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); - await context.removeCookies({ domain: 'www.example.com', path: '/api/v1' }); + await context.removeCookies({ domain: new URL(server.PREFIX).hostname, path: '/api/v1' }); expect(await page.evaluate('document.cookie')).toBe(''); - await page.goto('https://www.example.com/api/v2'); + await page.goto(server.PREFIX + '/api/v2'); expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); - await page.goto('https://www.example.org/api/v2'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/api/v2'); expect(await page.evaluate('document.cookie')).toBe('cookie4=4'); }); @@ -183,29 +183,29 @@ it('should remove cookies by name, domain and path', async ({ context, page, ser await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v1', }, { name: 'cookie2', value: '2', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/api/v2', }, { name: 'cookie1', value: '1', - domain: 'www.example.org', + domain: new URL(server.CROSS_PROCESS_PREFIX).hostname, path: '/api/v1', }, ]); - await page.goto('https://www.example.com/api/v1'); + await page.goto(server.PREFIX + '/api/v1'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); - await context.removeCookies({ name: 'cookie1', domain: 'www.example.com', path: '/api/v1' }); + await context.removeCookies({ name: 'cookie1', domain: new URL(server.PREFIX).hostname, path: '/api/v1' }); expect(await page.evaluate('document.cookie')).toBe(''); - await page.goto('https://www.example.com/api/v2'); + await page.goto(server.PREFIX + '/api/v2'); expect(await page.evaluate('document.cookie')).toBe('cookie2=2'); - await page.goto('https://www.example.org/api/v1'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/api/v1'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); }); @@ -213,17 +213,17 @@ it('should throw if empty object is passed', async ({ context, page, server }) = await context.addCookies([{ name: 'cookie1', value: '1', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', }, { name: 'cookie2', value: '2', - domain: 'www.example.com', + domain: new URL(server.PREFIX).hostname, path: '/', }, ]); - await page.goto('https://www.example.com/'); + await page.goto(server.PREFIX + '/'); expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); const error = await context.removeCookies({ }).catch(e => e); expect(error.message).toContain(`Either name, domain or path are required`); From 5eb8fea6162ac0d472dc72f18a6f46d18bde86b0 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 4 Mar 2024 19:36:58 -0800 Subject: [PATCH 056/141] chore: rewrite worker main through TestInfoImpl._runAsStage (#29644) --- .../src/utils/timeoutRunner.ts | 2 +- packages/playwright/src/common/testType.ts | 15 +- .../playwright/src/worker/fixtureRunner.ts | 63 ++--- packages/playwright/src/worker/testInfo.ts | 203 ++++++++------ .../playwright/src/worker/timeoutManager.ts | 22 +- packages/playwright/src/worker/workerMain.ts | 258 +++++++----------- tests/playwright-test/basic.spec.ts | 2 +- .../playwright-test/playwright.trace.spec.ts | 2 +- tests/playwright-test/timeout.spec.ts | 24 ++ 9 files changed, 296 insertions(+), 295 deletions(-) diff --git a/packages/playwright-core/src/utils/timeoutRunner.ts b/packages/playwright-core/src/utils/timeoutRunner.ts index fc4db8aed9..dd60551f62 100644 --- a/packages/playwright-core/src/utils/timeoutRunner.ts +++ b/packages/playwright-core/src/utils/timeoutRunner.ts @@ -45,11 +45,11 @@ export class TimeoutRunner { timeoutPromise: new ManualPromise(), }; try { + this._updateTimeout(running, this._timeout); const resultPromise = Promise.race([ cb(), running.timeoutPromise ]); - this._updateTimeout(running, this._timeout); return await resultPromise; } finally { this._updateTimeout(running, 0); diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 1411b249b4..35e2a4668c 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -21,7 +21,7 @@ import { wrapFunctionWithLocation } from '../transform/transform'; import type { FixturesWithLocation } from './config'; import type { Fixtures, TestType, TestDetails } from '../../types/test'; import type { Location } from '../../types/testReporter'; -import { getPackageManagerExecCommand } from 'playwright-core/lib/utils'; +import { getPackageManagerExecCommand, zones } from 'playwright-core/lib/utils'; const testTypeSymbol = Symbol('testType'); @@ -263,9 +263,16 @@ export class TestTypeImpl { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); - return testInfo._runAsStep({ category: 'test.step', title, box: options.box }, async () => { - // Make sure that internal "step" is not leaked to the user callback. - return await body(); + const step = testInfo._addStep({ wallTime: Date.now(), category: 'test.step', title, box: options.box }); + return await zones.run('stepZone', step, async () => { + try { + const result = await body(); + step.complete({}); + return result; + } catch (error) { + step.complete({ error }); + throw error; + } }); } diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 85c2dddab9..7405a83cab 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { formatLocation, debugTest, filterStackFile } from '../util'; +import { formatLocation, filterStackFile } from '../util'; import { ManualPromise } from 'playwright-core/lib/utils'; import type { TestInfoImpl } from './testInfo'; import type { FixtureDescription } from './timeoutManager'; @@ -65,22 +65,16 @@ class Fixture { return; } - testInfo._timeoutManager.setCurrentFixture(this._setupDescription); - const beforeStep = this._shouldGenerateStep ? testInfo._addStep({ + await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - category: 'fixture', + canTimeout: true, location: this._isInternalFixture ? this.registration.location : undefined, - wallTime: Date.now(), - }) : undefined; - try { + stepCategory: this._shouldGenerateStep ? 'fixture' : undefined, + }, async () => { + testInfo._timeoutManager.setCurrentFixture(this._setupDescription); await this._setupInternal(testInfo); - beforeStep?.complete({}); - } catch (error) { - beforeStep?.complete({ error }); - throw error; - } finally { testInfo._timeoutManager.setCurrentFixture(undefined); - } + }); } private async _setupInternal(testInfo: TestInfoImpl) { @@ -107,7 +101,6 @@ class Fixture { let called = false; const useFuncStarted = new ManualPromise(); - debugTest(`setup ${this.registration.name}`); const useFunc = async (value: any) => { if (called) throw new Error(`Cannot provide fixture value for the second time`); @@ -135,24 +128,18 @@ class Fixture { } async teardown(testInfo: TestInfoImpl) { - const afterStep = this._shouldGenerateStep ? testInfo?._addStep({ - wallTime: Date.now(), + await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - category: 'fixture', + canTimeout: true, location: this._isInternalFixture ? this.registration.location : undefined, - }) : undefined; - testInfo._timeoutManager.setCurrentFixture(this._teardownDescription); - try { + stepCategory: this._shouldGenerateStep ? 'fixture' : undefined, + }, async () => { + testInfo._timeoutManager.setCurrentFixture(this._teardownDescription); if (!this._teardownWithDepsComplete) this._teardownWithDepsComplete = this._teardownInternal(); await this._teardownWithDepsComplete; - afterStep?.complete({}); - } catch (error) { - afterStep?.complete({ error }); - throw error; - } finally { testInfo._timeoutManager.setCurrentFixture(undefined); - } + }); } private async _teardownInternal() { @@ -165,7 +152,6 @@ class Fixture { this._usages.clear(); } if (this._useFuncFinished) { - debugTest(`teardown ${this.registration.name}`); this._useFuncFinished.resolve(); await this._selfTeardownComplete; } @@ -214,14 +200,16 @@ export class FixtureRunner { collector.add(registration); } - async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl, onFixtureError: (error: Error) => void) { + async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { // Teardown fixtures in the reverse order. const fixtures = Array.from(this.instanceForId.values()).reverse(); const collector = new Set(); for (const fixture of fixtures) fixture._collectFixturesInTeardownOrder(scope, collector); - for (const fixture of collector) - await fixture.teardown(testInfo).catch(onFixtureError); + await testInfo._runAsStage({ title: `teardown ${scope} scope` }, async () => { + for (const fixture of collector) + await fixture.teardown(testInfo); + }); if (scope === 'test') this.testScopeClean = true; } @@ -250,17 +238,16 @@ export class FixtureRunner { this._collectFixturesInSetupOrder(this.pool!.resolve(name)!, collector); // Setup fixtures. - for (const registration of collector) { - const fixture = await this._setupFixtureForRegistration(registration, testInfo); - if (fixture.failed) - return null; - } + await testInfo._runAsStage({ title: 'setup fixtures', stopOnChildError: true }, async () => { + for (const registration of collector) + await this._setupFixtureForRegistration(registration, testInfo); + }); // Create params object. const params: { [key: string]: any } = {}; for (const name of names) { const registration = this.pool!.resolve(name)!; - const fixture = this.instanceForId.get(registration.id)!; + const fixture = this.instanceForId.get(registration.id); if (!fixture || fixture.failed) return null; params[name] = fixture.value; @@ -274,7 +261,9 @@ export class FixtureRunner { // Do not run the function when fixture setup has already failed. return null; } - return fn(params, testInfo); + await testInfo._runAsStage({ title: 'run function', canTimeout: true }, async () => { + await fn(params, testInfo); + }); } private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl): Promise { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index e79bd61529..792cb3d096 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -21,10 +21,10 @@ import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } fro import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; import { TimeoutManager } from './timeoutManager'; -import type { RunnableType, TimeSlot } from './timeoutManager'; +import type { RunnableDescription, RunnableType, TimeSlot } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; -import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, serializeError, trimLongString } from '../util'; +import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, serializeError, trimLongString } from '../util'; import { TestTracing } from './testTracing'; import type { Attachment } from './testTracing'; import type { StackFrame } from '@protocol/channels'; @@ -45,9 +45,26 @@ export interface TestStepInternal { infectParentStepsWithError?: boolean; box?: boolean; isSoft?: boolean; - forceNoParent?: boolean; + isStage?: boolean; } +export type TestStage = { + title: string; + location?: Location; + stepCategory?: 'hook' | 'fixture'; + runnableType?: RunnableType; + runnableSlot?: TimeSlot; + canTimeout?: boolean; + allowSkip?: boolean; + stopOnChildError?: boolean; + continueOnChildTimeout?: boolean; + + step?: TestStepInternal; + error?: Error; + triggeredSkip?: boolean; + triggeredTimeout?: boolean; +}; + export class TestInfoImpl implements TestInfo { private _onStepBegin: (payload: StepBeginPayload) => void; private _onStepEnd: (payload: StepEndPayload) => void; @@ -64,9 +81,9 @@ export class TestInfoImpl implements TestInfo { private readonly _requireFile: string; readonly _projectInternal: FullProjectInternal; readonly _configInternal: FullConfigInternal; - readonly _steps: TestStepInternal[] = []; + private readonly _steps: TestStepInternal[] = []; _onDidFinishTestFunction: (() => Promise) | undefined; - + private readonly _stages: TestStage[] = []; _hasNonRetriableError = false; // ------------ TestInfo fields ------------ @@ -218,36 +235,6 @@ export class TestInfoImpl implements TestInfo { } } - async _runWithTimeout(cb: () => Promise): Promise { - const timeoutError = await this._timeoutManager.runWithTimeout(cb); - // When interrupting, we arrive here with a timeoutError, but we should not - // consider it a timeout. - if (!this._wasInterrupted && timeoutError && !this._didTimeout) { - this._didTimeout = true; - const serialized = serializeError(timeoutError); - this.errors.push(serialized); - this._tracing.appendForError(serialized); - // Do not overwrite existing failure upon hook/teardown timeout. - if (this.status === 'passed' || this.status === 'skipped') - this.status = 'timedOut'; - } - this.duration = this._timeoutManager.defaultSlotTimings().elapsed | 0; - } - - async _runAndFailOnError(fn: () => Promise, skips?: 'allowSkips'): Promise { - try { - await fn(); - } catch (error) { - if (skips === 'allowSkips' && error instanceof SkipError) { - if (this.status === 'passed') - this.status = 'skipped'; - } else { - this._failWithError(error, true /* isHardError */, true /* retriable */); - return error; - } - } - } - private _findLastNonFinishedStep(filter: (step: TestStepInternal) => boolean) { let result: TestStepInternal | undefined; const visit = (step: TestStepInternal) => { @@ -259,33 +246,32 @@ export class TestInfoImpl implements TestInfo { return result; } + private _findLastStageStep() { + for (let i = this._stages.length - 1; i >= 0; i--) { + if (this._stages[i].step) + return this._stages[i].step; + } + } + _addStep(data: Omit): TestStepInternal { const stepId = `${data.category}@${++this._lastStepId}`; const rawStack = captureRawStack(); let parentStep: TestStepInternal | undefined; - if (data.category === 'hook' || data.category === 'fixture') { - // Predefined steps form a fixed hierarchy - find the last non-finished one. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); + if (data.isStage) { + // Predefined stages form a fixed hierarchy - use the current one as parent. + parentStep = this._findLastStageStep(); } else { parentStep = zones.zoneData('stepZone', rawStack!) || undefined; - if (parentStep?.category === 'hook' || parentStep?.category === 'fixture') { - // Prefer last non-finished predefined step over the on-stack one, because - // some predefined steps may be missing on the stack. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); - } else if (!parentStep) { - if (data.category === 'test.step') { - // Nest test.step without a good stack in the last non-finished predefined step like a hook. - parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook'); - } else { - // Do not nest chains of route.continue. - parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); - } + if (!parentStep && data.category !== 'test.step') { + // API steps (but not test.step calls) can be nested by time, instead of by stack. + // However, do not nest chains of route.continue by checking the title. + parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); + } + if (!parentStep) { + // If no parent step on stack, assume the current stage as parent. + parentStep = this._findLastStageStep(); } - } - if (data.forceNoParent) { - // This is used to reset step hierarchy after test timeout. - parentStep = undefined; } const filteredStack = filteredStackTrace(rawStack); @@ -366,6 +352,13 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } + _unhandledError(error: Error) { + this._failWithError(error, true /* isHardError */, true /* retriable */); + const stage = this._stages[this._stages.length - 1]; + if (stage) + stage.error = stage.error ?? error; + } + _failWithError(error: Error, isHardError: boolean, retriable: boolean) { if (!retriable) this._hasNonRetriableError = true; @@ -387,33 +380,91 @@ export class TestInfoImpl implements TestInfo { this._tracing.appendForError(serialized); } - async _runAsStepWithRunnable( - stepInfo: Omit & { - wallTime?: number, - runnableType: RunnableType; - runnableSlot?: TimeSlot; - }, cb: (step: TestStepInternal) => Promise): Promise { - return await this._timeoutManager.withRunnable({ - type: stepInfo.runnableType, - slot: stepInfo.runnableSlot, - location: stepInfo.location, - }, async () => { - return await this._runAsStep(stepInfo, cb); - }); - } + async _runAsStage(stage: TestStage, cb: () => Promise) { + // Inherit some properties from parent. + const parent = this._stages[this._stages.length - 1]; + stage.allowSkip = stage.allowSkip ?? parent?.allowSkip ?? false; - async _runAsStep(stepInfo: Omit & { wallTime?: number }, cb: (step: TestStepInternal) => Promise): Promise { - const step = this._addStep({ wallTime: Date.now(), ...stepInfo }); - return await zones.run('stepZone', step, async () => { + if (parent?.allowSkip && parent?.triggeredSkip) { + // Do not run more child steps after "skip" has been triggered. + debugTest(`ignored stage "${stage.title}" after previous skip`); + return; + } + if (parent?.stopOnChildError && parent?.error) { + // Do not run more child steps after a previous one failed. + debugTest(`ignored stage "${stage.title}" after previous error`); + return; + } + if (parent?.triggeredTimeout && !parent?.continueOnChildTimeout) { + // Do not run more child steps after a previous one timed out. + debugTest(`ignored stage "${stage.title}" after previous timeout`); + return; + } + + if (debugTest.enabled) { + const location = stage.location ? ` at "${formatLocation(stage.location)}"` : ``; + debugTest(`started stage "${stage.title}"${location}`); + } + stage.step = stage.stepCategory ? this._addStep({ title: stage.title, category: stage.stepCategory, location: stage.location, wallTime: Date.now(), isStage: true }) : undefined; + this._stages.push(stage); + + let runnable: RunnableDescription | undefined; + if (stage.canTimeout) { + // Choose the deepest runnable configuration. + runnable = { type: 'test' }; + for (const s of this._stages) { + if (s.runnableType) { + runnable.type = s.runnableType; + runnable.location = s.location; + } + if (s.runnableSlot) + runnable.slot = s.runnableSlot; + } + } + + const timeoutError = await this._timeoutManager.withRunnable(runnable, async () => { try { - const result = await cb(step); - step.complete({}); - return result; + await cb(); } catch (e) { - step.complete({ error: e instanceof SkipError ? undefined : e }); - throw e; + if (stage.allowSkip && (e instanceof SkipError)) { + stage.triggeredSkip = true; + if (this.status === 'passed') + this.status = 'skipped'; + } else { + // Prefer the first error. + stage.error = stage.error ?? e; + this._failWithError(e, true /* isHardError */, true /* retriable */); + } } }); + if (timeoutError) + stage.triggeredTimeout = true; + + // When interrupting, we arrive here with a timeoutError, but we should not + // consider it a timeout. + if (!this._wasInterrupted && !this._didTimeout && timeoutError) { + stage.error = stage.error ?? timeoutError; + this._didTimeout = true; + const serialized = serializeError(timeoutError); + this.errors.push(serialized); + this._tracing.appendForError(serialized); + // Do not overwrite existing failure upon hook/teardown timeout. + if (this.status === 'passed' || this.status === 'skipped') + this.status = 'timedOut'; + } + + if (parent) { + // Notify parent about child error, skip and timeout. + parent.error = parent.error ?? stage.error; + parent.triggeredSkip = parent.triggeredSkip || stage.triggeredSkip; + parent.triggeredTimeout = parent.triggeredTimeout || stage.triggeredTimeout; + } + + if (this._stages[this._stages.length - 1] !== stage) + throw new Error(`Internal error: inconsistent stages!`); + this._stages.pop(); + stage.step?.complete({ error: stage.error }); + debugTest(`finished stage "${stage.title}"`); } _isFailure() { diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 94e182f682..a4dc7963ca 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -56,14 +56,22 @@ export class TimeoutManager { this._timeoutRunner.interrupt(); } - async withRunnable(runnable: RunnableDescription, cb: () => Promise): Promise { + async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { + if (!runnable) { + await cb(); + return; + } const existingRunnable = this._runnable; const effectiveRunnable = { ...runnable }; if (!effectiveRunnable.slot) effectiveRunnable.slot = this._runnable.slot; this._updateRunnables(effectiveRunnable, undefined); try { - return await cb(); + await this._timeoutRunner.run(cb); + } catch (error) { + if (!(error instanceof TimeoutRunnerError)) + throw error; + return this._createTimeoutError(); } finally { this._updateRunnables(existingRunnable, undefined); } @@ -85,16 +93,6 @@ export class TimeoutManager { this._timeoutRunner.updateTimeout(slot.timeout); } - async runWithTimeout(cb: () => Promise): Promise { - try { - await this._timeoutRunner.run(cb); - } catch (error) { - if (!(error instanceof TimeoutRunnerError)) - throw error; - return this._createTimeoutError(); - } - } - setTimeout(timeout: number) { const slot = this._currentSlot(); if (!slot.timeout) diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index d678e485aa..319485cf11 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -15,7 +15,7 @@ */ import { colors } from 'playwright-core/lib/utilsBundle'; -import { debugTest, formatLocation, relativeFilePath, serializeError } from '../util'; +import { debugTest, relativeFilePath, serializeError } from '../util'; import { type TestBeginPayload, type TestEndPayload, type RunPayload, type DonePayload, type WorkerInitParams, type TeardownErrorsPayload, stdioChunkToParams } from '../common/ipc'; import { setCurrentTestInfo, setIsWorkerProcess } from '../common/globals'; import { deserializeConfig } from '../common/configLoader'; @@ -23,14 +23,13 @@ import type { Suite, TestCase } from '../common/test'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import { FixtureRunner } from './fixtureRunner'; import { ManualPromise, gracefullyCloseAll, removeFolders } from 'playwright-core/lib/utils'; -import { TestInfoImpl } from './testInfo'; +import { TestInfoImpl, type TestStage } from './testInfo'; import { ProcessRunner } from '../common/process'; import { loadTestFile } from '../common/testLoader'; import { applyRepeatEachIndex, bindFileSuiteToProject, filterTestsRemoveEmptySuites } from '../common/suiteUtils'; import { PoolBuilder } from '../common/poolBuilder'; import type { TestInfoError } from '../../types/test'; import type { Location } from '../../types/testReporter'; -import type { FixtureScope } from '../common/fixtures'; import { inheritFixutreNames } from '../common/fixtures'; export class WorkerMain extends ProcessRunner { @@ -144,30 +143,12 @@ export class WorkerMain extends ProcessRunner { } } - private async _teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { - const error = await this._teardownScopeAndReturnFirstError(scope, testInfo); - if (error) - throw error; - } - - private async _teardownScopeAndReturnFirstError(scope: FixtureScope, testInfo: TestInfoImpl): Promise { - let error: Error | undefined; - await this._fixtureRunner.teardownScope(scope, testInfo, e => { - testInfo._failWithError(e, true, false); - if (error === undefined) - error = e; - }); - return error; - } - private async _teardownScopes() { // TODO: separate timeout for teardown? const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); - await fakeTestInfo._timeoutManager.withRunnable({ type: 'teardown' }, async () => { - await fakeTestInfo._runWithTimeout(async () => { - await this._teardownScopeAndReturnFirstError('test', fakeTestInfo); - await this._teardownScopeAndReturnFirstError('worker', fakeTestInfo); - }); + await fakeTestInfo._runAsStage({ title: 'teardown scopes', runnableType: 'teardown' }, async () => { + await this._fixtureRunner.teardownScope('test', fakeTestInfo); + await this._fixtureRunner.teardownScope('worker', fakeTestInfo); }); this._fatalErrors.push(...fakeTestInfo.errors); } @@ -185,7 +166,7 @@ export class WorkerMain extends ProcessRunner { // and unhandled errors - both lead to the test failing. This is good for regular tests, // so that you can, e.g. expect() from inside an event handler. The test fails, // and we restart the worker. - this._currentTest._failWithError(error, true /* isHardError */, true /* retriable */); + this._currentTest._unhandledError(error); // For tests marked with test.fail(), this might be a problem when unhandled error // is not coming from the user test code (legit failure), but from fixtures or test runner. @@ -323,11 +304,10 @@ export class WorkerMain extends ProcessRunner { this._lastRunningTests.push(test); if (this._lastRunningTests.length > 10) this._lastRunningTests.shift(); - let didFailBeforeAllForSuite: Suite | undefined; let shouldRunAfterEachHooks = false; - await testInfo._runWithTimeout(async () => { - const traceError = await testInfo._runAndFailOnError(async () => { + await testInfo._runAsStage({ title: 'setup and test', runnableType: 'test', allowSkip: true, stopOnChildError: true }, async () => { + await testInfo._runAsStage({ title: 'start tracing', canTimeout: true }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -339,8 +319,6 @@ export class WorkerMain extends ProcessRunner { throw new Error(`"trace" option cannot be a function`); await testInfo._tracing.startIfNeeded(traceFixtureRegistration.fn); }); - if (traceError) - return; if (this._isStopped || isSkipped) { // Two reasons to get here: @@ -348,45 +326,24 @@ export class WorkerMain extends ProcessRunner { // - Worker is requested to stop, but was not able to run full cleanup yet. // We should skip the test, but run the cleanup. testInfo.status = 'skipped'; - didFailBeforeAllForSuite = undefined; return; } await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - await testInfo._runAsStep({ category: 'hook', title: 'Before Hooks' }, async step => { + const beforeHooksStage: TestStage = { title: 'Before Hooks', stepCategory: 'hook', stopOnChildError: true }; + await testInfo._runAsStage(beforeHooksStage, async () => { // Run "beforeAll" hooks, unless already run during previous tests. - for (const suite of suites) { - didFailBeforeAllForSuite = suite; // Assume failure, unless reset below. - const beforeAllError = await this._runBeforeAllHooksForSuite(suite, testInfo); - if (beforeAllError) { - step.complete({ error: beforeAllError }); - return; - } - didFailBeforeAllForSuite = undefined; - if (testInfo.expectedStatus === 'skipped') - return; - } + for (const suite of suites) + await this._runBeforeAllHooksForSuite(suite, testInfo); - const beforeEachError = await testInfo._runAndFailOnError(async () => { - // Run "beforeEach" hooks. Once started with "beforeEach", we must run all "afterEach" hooks as well. - shouldRunAfterEachHooks = true; - await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); - }, 'allowSkips'); - if (beforeEachError) { - step.complete({ error: beforeEachError }); - return; - } - if (testInfo.expectedStatus === 'skipped') - return; + // Run "beforeEach" hooks. Once started with "beforeEach", we must run all "afterEach" hooks as well. + shouldRunAfterEachHooks = !beforeHooksStage.error && !beforeHooksStage.triggeredSkip && !beforeHooksStage.triggeredTimeout; + await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); - const fixturesError = await testInfo._runAndFailOnError(async () => { - // Setup fixtures required by the test. - testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test'); - }, 'allowSkips'); - if (fixturesError) - step.complete({ error: fixturesError }); + // Setup fixtures required by the test. + testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test'); }); if (testFunctionParams === null) { @@ -394,58 +351,50 @@ export class WorkerMain extends ProcessRunner { return; } - await testInfo._runAndFailOnError(async () => { + await testInfo._runAsStage({ title: 'test function', canTimeout: true }, async () => { // Now run the test itself. - debugTest(`test function started`); const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); - debugTest(`test function finished`); - }, 'allowSkips'); + }); }); - if (didFailBeforeAllForSuite) { - // This will inform dispatcher that we should not run more tests from this group - // because we had a beforeAll error. - // This behavior avoids getting the same common error for each test. - this._skipRemainingTestsInSuite = didFailBeforeAllForSuite; - } + // Update duration, so it is available in fixture teardown and afterEach hooks. + testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; // A timed-out test gets a full additional timeout to run after hooks. const afterHooksSlot = testInfo._didTimeout ? { timeout: this._project.project.timeout, elapsed: 0 } : undefined; - await testInfo._runAsStepWithRunnable({ category: 'hook', title: 'After Hooks', runnableType: 'afterHooks', runnableSlot: afterHooksSlot, forceNoParent: true }, async step => { - let firstAfterHooksError: Error | undefined; - await testInfo._runWithTimeout(async () => { - // Note: do not wrap all teardown steps together, because failure in any of them - // does not prevent further teardown steps from running. - + await testInfo._runAsStage({ + title: 'After Hooks', + stepCategory: 'hook', + runnableType: 'afterHooks', + runnableSlot: afterHooksSlot, + continueOnChildTimeout: true, // Make sure the full cleanup still runs after regular cleanup timeout. + }, async () => { + // Wrap cleanup steps in a stage, to stop running after one of them times out. + await testInfo._runAsStage({ title: 'regular cleanup' }, async () => { // Run "immediately upon test function finish" callback. - debugTest(`on-test-function-finish callback started`); - const didFinishTestFunctionError = await testInfo._runAndFailOnError(async () => testInfo._onDidFinishTestFunction?.()); - firstAfterHooksError = firstAfterHooksError || didFinishTestFunctionError; - debugTest(`on-test-function-finish callback finished`); + await testInfo._runAsStage({ title: 'on-test-function-finish', canTimeout: true }, async () => testInfo._onDidFinishTestFunction?.()); // Run "afterEach" hooks, unless we failed at beforeAll stage. - if (shouldRunAfterEachHooks) { - const afterEachError = await testInfo._runAndFailOnError(() => this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo)); - firstAfterHooksError = firstAfterHooksError || afterEachError; - } + if (shouldRunAfterEachHooks) + await this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo); // Teardown test-scoped fixtures. Attribute to 'test' so that users understand // they should probably increase the test timeout to fix this issue. - debugTest(`tearing down test scope started`); - const testScopeError = await this._teardownScopeAndReturnFirstError('test', testInfo); - debugTest(`tearing down test scope finished`); - firstAfterHooksError = firstAfterHooksError || testScopeError; + await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { + await this._fixtureRunner.teardownScope('test', testInfo); + }); // Run "afterAll" hooks for suites that are not shared with the next test. // In case of failure the worker will be stopped and we have to make sure that afterAll // hooks run before worker fixtures teardown. - for (const suite of reversedSuites) { - if (!nextSuites.has(suite) || testInfo._isFailure()) { - const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo); - firstAfterHooksError = firstAfterHooksError || afterAllError; + // Continue running "afterAll" hooks even after some of them timeout. + await testInfo._runAsStage({ title: `after hooks suites`, continueOnChildTimeout: true }, async () => { + for (const suite of reversedSuites) { + if (!nextSuites.has(suite) || testInfo._isFailure()) + await this._runAfterAllHooksForSuite(suite, testInfo); } - } + }); }); if (testInfo._isFailure()) @@ -457,39 +406,28 @@ export class WorkerMain extends ProcessRunner { this._didRunFullCleanup = true; // Give it more time for the full cleanup. - await testInfo._runWithTimeout(async () => { - debugTest(`running full cleanup after the failure`); - - const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._timeoutManager.withRunnable({ type: 'teardown', slot: teardownSlot }, async () => { - // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. - debugTest(`tearing down test scope started`); - const testScopeError = await this._teardownScopeAndReturnFirstError('test', testInfo); - debugTest(`tearing down test scope finished`); - firstAfterHooksError = firstAfterHooksError || testScopeError; - - for (const suite of reversedSuites) { - const afterAllError = await this._runAfterAllHooksForSuite(suite, testInfo); - firstAfterHooksError = firstAfterHooksError || afterAllError; - } - - // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. - debugTest(`tearing down worker scope started`); - const workerScopeError = await this._teardownScopeAndReturnFirstError('worker', testInfo); - debugTest(`tearing down worker scope finished`); - firstAfterHooksError = firstAfterHooksError || workerScopeError; + const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + await testInfo._runAsStage({ title: 'full cleanup', runnableType: 'teardown', runnableSlot: teardownSlot }, async () => { + // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. + await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { + await this._fixtureRunner.teardownScope('test', testInfo); }); + + for (const suite of reversedSuites) + await this._runAfterAllHooksForSuite(suite, testInfo); + + // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. + await this._fixtureRunner.teardownScope('worker', testInfo); }); } - - if (firstAfterHooksError) - step.complete({ error: firstAfterHooksError }); }); - await testInfo._runAndFailOnError(async () => { + await testInfo._runAsStage({ title: 'stop tracing' }, async () => { await testInfo._tracing.stopIfNeeded(); }); + testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; + this._currentTest = null; setCurrentTestInfo(null); this.dispatchEvent('testEnd', buildTestEndPayload(testInfo)); @@ -529,73 +467,67 @@ export class WorkerMain extends ProcessRunner { return; const extraAnnotations: Annotation[] = []; this._activeSuites.set(suite, extraAnnotations); - return await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations); + await this._runAllHooksForSuite(suite, testInfo, 'beforeAll', extraAnnotations); } private async _runAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl, type: 'beforeAll' | 'afterAll', extraAnnotations?: Annotation[]) { - const allowSkips = type === 'beforeAll'; - let firstError: Error | undefined; - for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { - debugTest(`${hook.type} hook at "${formatLocation(hook.location)}" started`); - const error = await testInfo._runAndFailOnError(async () => { + // Always run all the hooks, and capture the first error. + await testInfo._runAsStage({ title: `${type} hooks`, continueOnChildTimeout: true }, async () => { + for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { // Separate time slot for each beforeAll/afterAll hook. const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStepWithRunnable({ - category: 'hook', + const stage: TestStage = { title: hook.title, - location: hook.location, runnableType: hook.type, runnableSlot: timeSlot, - }, async () => { + stepCategory: 'hook', + location: hook.location, + continueOnChildTimeout: true, // Make sure to teardown the scope even after hook timeout. + }; + await testInfo._runAsStage(stage, async () => { const existingAnnotations = new Set(testInfo.annotations); - try { - await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); - } finally { - if (extraAnnotations) { - // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. - const newAnnotations = testInfo.annotations.filter(a => !existingAnnotations.has(a)); - extraAnnotations.push(...newAnnotations); - } - // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. - // Note: we must teardown even after hook fails, because we'll run more hooks. - await this._teardownScope('test', testInfo); + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); + if (extraAnnotations) { + // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. + const newAnnotations = testInfo.annotations.filter(a => !existingAnnotations.has(a)); + extraAnnotations.push(...newAnnotations); } + // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. + // Note: we must teardown even after hook fails, because we'll run more hooks. + await this._fixtureRunner.teardownScope('test', testInfo); }); - }, allowSkips ? 'allowSkips' : undefined); - firstError = firstError || error; - debugTest(`${hook.type} hook at "${formatLocation(hook.location)}" finished`); - // Skip inside a beforeAll hook/modifier prevents others from running. - if (allowSkips && testInfo.expectedStatus === 'skipped') - break; - } - return firstError; + if ((stage.error || stage.triggeredTimeout) && type === 'beforeAll' && !this._skipRemainingTestsInSuite) { + // This will inform dispatcher that we should not run more tests from this group + // because we had a beforeAll error. + // This behavior avoids getting the same common error for each test. + this._skipRemainingTestsInSuite = suite; + } + } + }); } - private async _runAfterAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl): Promise { + private async _runAfterAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl) { if (!this._activeSuites.has(suite)) return; this._activeSuites.delete(suite); - return await this._runAllHooksForSuite(suite, testInfo, 'afterAll'); + await this._runAllHooksForSuite(suite, testInfo, 'afterAll'); } private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl) { - const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); - let error: Error | undefined; - for (const hook of hooks) { - try { - await testInfo._runAsStepWithRunnable({ - category: 'hook', + // Wrap hooks in a stage, to always run all of them and capture the first error. + await testInfo._runAsStage({ title: `${type} hooks` }, async () => { + const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); + for (const hook of hooks) { + await testInfo._runAsStage({ title: hook.title, - location: hook.location, runnableType: hook.type, - }, () => this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test')); - } catch (e) { - // Always run all the hooks, and capture the first error. - error = error || e; + location: hook.location, + stepCategory: 'hook', + }, async () => { + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test'); + }); } - } - if (error) - throw error; + }); } } diff --git a/tests/playwright-test/basic.spec.ts b/tests/playwright-test/basic.spec.ts index 4c4b28ceeb..6476255936 100644 --- a/tests/playwright-test/basic.spec.ts +++ b/tests/playwright-test/basic.spec.ts @@ -559,7 +559,7 @@ test('should not allow mixing test types', async ({ runInlineTest }) => { export const test2 = test.extend({ value: 42, }); - + test.describe("test1 suite", () => { test2("test 2", async () => {}); }); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 41ce802500..95450bf483 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -752,7 +752,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip')); - const attachedScreenshots = trace.actionTree.filter(s => s === ` attach "screenshot"`); + const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); // One screenshot for the page, no screenshot for pdf page since it should have failed. expect(attachedScreenshots.length).toBe(1); }); diff --git a/tests/playwright-test/timeout.spec.ts b/tests/playwright-test/timeout.spec.ts index 741b10e62f..bed9429ad3 100644 --- a/tests/playwright-test/timeout.spec.ts +++ b/tests/playwright-test/timeout.spec.ts @@ -455,3 +455,27 @@ test('should respect test.describe.configure', async ({ runInlineTest }) => { expect(result.output).toContain('test1-1000'); expect(result.output).toContain('test2-2000'); }); + +test('beforeEach timeout should prevent others from running', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test.beforeEach(async () => { + console.log('\\n%%beforeEach1'); + await new Promise(f => setTimeout(f, 2500)); + }); + test.beforeEach(async () => { + console.log('\\n%%beforeEach2'); + }); + test('test', async ({}) => { + }); + test.afterEach(async () => { + console.log('\\n%%afterEach'); + await new Promise(f => setTimeout(f, 1500)); + }); + ` + }, { timeout: 2000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.outputLines).toEqual(['beforeEach1', 'afterEach']); +}); From e314b83e56817a3b0f1766851b2becf7fe488181 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 4 Mar 2024 19:52:20 -0800 Subject: [PATCH 057/141] chore: iterate towards tele reporter reuse in vscode (2) (#29812) --- .../playwright/src/isomorphic/teleReceiver.ts | 36 ++++++++----------- packages/playwright/src/reporters/blob.ts | 2 +- packages/playwright/src/reporters/merge.ts | 3 +- .../playwright/src/reporters/teleEmitter.ts | 15 +++++--- packages/playwright/src/runner/reporters.ts | 2 +- packages/playwright/src/runner/testServer.ts | 20 ++++------- .../src/runner/testServerInterface.ts | 4 +-- packages/playwright/src/runner/uiMode.ts | 4 +-- packages/trace-viewer/src/ui/uiModeView.tsx | 24 +++++++------ 9 files changed, 52 insertions(+), 58 deletions(-) diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index 8b64ce747a..db573a0f6f 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -122,10 +122,9 @@ export type JsonEvent = { }; type TeleReporterReceiverOptions = { - pathSeparator: string; mergeProjects: boolean; mergeTestCases: boolean; - internString?: StringIntern; + resolvePath: (rootDir: string, relativePath: string) => string; configOverrides?: Pick; }; @@ -242,7 +241,6 @@ export class TeleReporterReceiver { testResult.workerIndex = payload.workerIndex; testResult.parallelIndex = payload.parallelIndex; testResult.setStartTimeNumber(payload.startTime); - testResult.statusEx = 'running'; this._reporter.onTestBegin?.(test, testResult); } @@ -251,22 +249,21 @@ export class TeleReporterReceiver { test.timeout = testEndPayload.timeout; test.expectedStatus = testEndPayload.expectedStatus; test.annotations = testEndPayload.annotations; - const result = test.resultsMap.get(payload.id)!; + const result = test._resultsMap.get(payload.id)!; result.duration = payload.duration; result.status = payload.status; - result.statusEx = payload.status; result.errors = payload.errors; result.error = result.errors?.[0]; result.attachments = this._parseAttachments(payload.attachments); this._reporter.onTestEnd?.(test, result); // Free up the memory as won't see these step ids. - result.stepMap = new Map(); + result._stepMap = new Map(); } private _onStepBegin(testId: string, resultId: string, payload: JsonTestStepStart) { const test = this._tests.get(testId)!; - const result = test.resultsMap.get(resultId)!; - const parentStep = payload.parentStepId ? result.stepMap.get(payload.parentStepId) : undefined; + const result = test._resultsMap.get(resultId)!; + const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined; const location = this._absoluteLocation(payload.location); const step = new TeleTestStep(payload, parentStep, location); @@ -274,14 +271,14 @@ export class TeleReporterReceiver { parentStep.steps.push(step); else result.steps.push(step); - result.stepMap.set(payload.id, step); + result._stepMap.set(payload.id, step); this._reporter.onStepBegin?.(test, result, step); } private _onStepEnd(testId: string, resultId: string, payload: JsonTestStepEnd) { const test = this._tests.get(testId)!; - const result = test.resultsMap.get(resultId)!; - const step = result.stepMap.get(payload.id)!; + const result = test._resultsMap.get(resultId)!; + const step = result._stepMap.get(payload.id)!; step.duration = payload.duration; step.error = payload.error; this._reporter.onStepEnd?.(test, result, step); @@ -294,7 +291,7 @@ export class TeleReporterReceiver { private _onStdIO(type: JsonStdIOType, testId: string | undefined, resultId: string | undefined, data: string, isBase64: boolean) { const chunk = isBase64 ? ((globalThis as any).Buffer ? Buffer.from(data, 'base64') : atob(data)) : data; const test = testId ? this._tests.get(testId) : undefined; - const result = test && resultId ? test.resultsMap.get(resultId) : undefined; + const result = test && resultId ? test._resultsMap.get(resultId) : undefined; if (type === 'stdout') { result?.stdout.push(chunk); this._reporter.onStdOut?.(chunk, test, result); @@ -407,11 +404,7 @@ export class TeleReporterReceiver { private _absolutePath(relativePath?: string): string | undefined { if (relativePath === undefined) return; - return this._internString(this._rootDir + this._options.pathSeparator + relativePath); - } - - private _internString(s: string): string { - return this._options.internString ? this._options.internString(s) : s; + return this._options.resolvePath(this._rootDir, relativePath); } } @@ -475,7 +468,7 @@ export class TeleTestCase implements reporterTypes.TestCase { repeatEachIndex = 0; id: string; - resultsMap = new Map(); + _resultsMap = new Map(); constructor(id: string, title: string, location: reporterTypes.Location, repeatEachIndex: number) { this.id = id; @@ -515,13 +508,13 @@ export class TeleTestCase implements reporterTypes.TestCase { _clearResults() { this.results = []; - this.resultsMap.clear(); + this._resultsMap.clear(); } _createTestResult(id: string): TeleTestResult { const result = new TeleTestResult(this.results.length); this.results.push(result); - this.resultsMap.set(id, result); + this._resultsMap.set(id, result); return result; } } @@ -571,8 +564,7 @@ class TeleTestResult implements reporterTypes.TestResult { errors: reporterTypes.TestResult['errors'] = []; error: reporterTypes.TestResult['error']; - stepMap: Map = new Map(); - statusEx: reporterTypes.TestResult['status'] | 'scheduled' | 'running' = 'scheduled'; + _stepMap: Map = new Map(); private _startTime: number = 0; diff --git a/packages/playwright/src/reporters/blob.ts b/packages/playwright/src/reporters/blob.ts index 03848a4c76..019fa8b7ca 100644 --- a/packages/playwright/src/reporters/blob.ts +++ b/packages/playwright/src/reporters/blob.ts @@ -50,7 +50,7 @@ export class BlobReporter extends TeleReporterEmitter { private _reportName!: string; constructor(options: BlobReporterOptions) { - super(message => this._messages.push(message), false); + super(message => this._messages.push(message)); this._options = options; if (this._options.fileName && !this._options.fileName.endsWith('.zip')) throw new Error(`Blob report file name must end with .zip extension: ${this._options.fileName}`); diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 4e4d02d697..0b21182bba 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -54,10 +54,9 @@ export async function createMergedReport(config: FullConfigInternal, dir: string // If explicit config is provided, use platform path separator, otherwise use the one from the report (if any). const pathSeparator = rootDirOverride ? path.sep : (eventData.pathSeparatorFromMetadata ?? path.sep); const receiver = new TeleReporterReceiver(multiplexer, { - pathSeparator, mergeProjects: false, mergeTestCases: false, - internString: s => stringPool.internString(s), + resolvePath: (rootDir, relativePath) => stringPool.internString(rootDir + pathSeparator + relativePath), configOverrides: config.config, }); printStatus(`processing test events`); diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index e06963663e..2baef221aa 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -21,14 +21,19 @@ import type * as teleReceiver from '../isomorphic/teleReceiver'; import { serializeRegexPatterns } from '../isomorphic/teleReceiver'; import type { ReporterV2 } from './reporterV2'; +export type TeleReporterEmitterOptions = { + omitOutput?: boolean; + omitBuffers?: boolean; +}; + export class TeleReporterEmitter implements ReporterV2 { private _messageSink: (message: teleReceiver.JsonEvent) => void; private _rootDir!: string; - private _skipBuffers: boolean; + private _emitterOptions: TeleReporterEmitterOptions; - constructor(messageSink: (message: teleReceiver.JsonEvent) => void, skipBuffers: boolean) { + constructor(messageSink: (message: teleReceiver.JsonEvent) => void, options: TeleReporterEmitterOptions = {}) { this._messageSink = messageSink; - this._skipBuffers = skipBuffers; + this._emitterOptions = options; } version(): 'v2' { @@ -113,6 +118,8 @@ export class TeleReporterEmitter implements ReporterV2 { } private _onStdIO(type: teleReceiver.JsonStdIOType, chunk: string | Buffer, test: void | reporterTypes.TestCase, result: void | reporterTypes.TestResult): void { + if (this._emitterOptions.omitOutput) + return; const isBase64 = typeof chunk !== 'string'; const data = isBase64 ? chunk.toString('base64') : chunk; this._messageSink({ @@ -224,7 +231,7 @@ export class TeleReporterEmitter implements ReporterV2 { return { ...a, // There is no Buffer in the browser, so there is no point in sending the data there. - base64: (a.body && !this._skipBuffers) ? a.body.toString('base64') : undefined, + base64: (a.body && !this._emitterOptions.omitBuffers) ? a.body.toString('base64') : undefined, }; }); } diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 3ec842f7e7..3ab0ca18c6 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -88,7 +88,7 @@ export async function createReporterForTestServer(config: FullConfigInternal, fi function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'ui' | 'merge', send?: (message: any) => void) { return { configDir: config.configDir, - send, + _send: send, _mode: mode, }; } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 4df7f7fd49..38c950d164 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -114,13 +114,13 @@ class Dispatcher implements TestServerInterface { async listTests(params: { configFile: string; locations: string[]; - reporters: { file: string, event: string }[]; + reporter: string; env: NodeJS.ProcessEnv; }) { const config = await this._loadConfig(params.configFile); config.cliArgs = params.locations || []; - const wireReporters = await this._wireReporters(config, 'list', params.reporters); - const reporter = new InternalReporter(new Multiplexer(wireReporters)); + const wireReporter = await createReporterForTestServer(config, params.reporter, 'list', message => this._dispatchEvent('report', message)); + const reporter = new InternalReporter(new Multiplexer([wireReporter])); const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: true }); const testRun = new TestRun(config, reporter); reporter.onConfigure(config.config); @@ -138,7 +138,7 @@ class Dispatcher implements TestServerInterface { async test(params: { configFile: string; locations: string[]; - reporters: { file: string, event: string }[]; + reporter: string; env: NodeJS.ProcessEnv; headed?: boolean; oneWorker?: boolean; @@ -169,9 +169,9 @@ class Dispatcher implements TestServerInterface { config.cliGrep = params.grep; config.cliProjectFilter = params.projects?.length ? params.projects : undefined; - const wireReporters = await this._wireReporters(config, 'test', params.reporters); + const wireReporter = await createReporterForTestServer(config, params.reporter, 'test', message => this._dispatchEvent('report', message)); const configReporters = await createReporters(config, 'test'); - const reporter = new InternalReporter(new Multiplexer([...configReporters, ...wireReporters])); + const reporter = new InternalReporter(new Multiplexer([...configReporters, wireReporter])); const taskRunner = createTaskRunnerForTestServer(config, reporter); const testRun = new TestRun(config, reporter); reporter.onConfigure(config.config); @@ -186,14 +186,6 @@ class Dispatcher implements TestServerInterface { await run; } - private async _wireReporters(config: FullConfigInternal, mode: 'test' | 'list', reporters: { file: string, event: string }[]) { - return await Promise.all(reporters.map(r => { - return createReporterForTestServer(config, r.file, mode, message => { - this._dispatchEvent(r.event, message); - }); - })); - } - async findRelatedTestFiles(params: { configFile: string; files: string[]; diff --git a/packages/playwright/src/runner/testServerInterface.ts b/packages/playwright/src/runner/testServerInterface.ts index 23c404da84..1e844d8123 100644 --- a/packages/playwright/src/runner/testServerInterface.ts +++ b/packages/playwright/src/runner/testServerInterface.ts @@ -33,13 +33,13 @@ export interface TestServerInterface { listTests(params: { configFile: string; locations: string[]; - reporters: { file: string, event: string }[]; + reporter: string; }): Promise; test(params: { configFile: string; locations: string[]; - reporters: { file: string, event: string }[]; + reporter: string; headed?: boolean; oneWorker?: boolean; trace?: 'on' | 'off'; diff --git a/packages/playwright/src/runner/uiMode.ts b/packages/playwright/src/runner/uiMode.ts index 61a5aaddce..ac6c6bf11b 100644 --- a/packages/playwright/src/runner/uiMode.ts +++ b/packages/playwright/src/runner/uiMode.ts @@ -167,7 +167,7 @@ class UIMode { } private async _listTests() { - const reporter = new InternalReporter(new TeleReporterEmitter(e => this._dispatchEvent('listReport', e), true)); + const reporter = new InternalReporter(new TeleReporterEmitter(e => this._dispatchEvent('listReport', e), { omitBuffers: true })); this._config.cliListOnly = true; this._config.testIdMatcher = undefined; const taskRunner = createTaskRunnerForList(this._config, reporter, 'out-of-process', { failOnLoadErrors: false }); @@ -195,7 +195,7 @@ class UIMode { this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); const reporters = await createReporters(this._config, 'ui'); - reporters.push(new TeleReporterEmitter(e => this._dispatchEvent('testReport', e), true)); + reporters.push(new TeleReporterEmitter(e => this._dispatchEvent('testReport', e), { omitBuffers: true })); const reporter = new InternalReporter(new Multiplexer(reporters)); const taskRunner = createTaskRunnerForWatch(this._config, reporter); const testRun = new TestRun(this._config, reporter); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 945294fa94..b0c392884f 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -22,7 +22,7 @@ import { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; -import type { FullConfig, Suite, TestCase, Location, TestError } from 'playwright/types/testReporter'; +import type { FullConfig, Suite, TestCase, Location, TestError, TestResult } from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; import { idForAction, MultiTraceModel } from './modelUtil'; import type { SourceLocation } from './modelUtil'; @@ -151,7 +151,8 @@ export const UIModeView: React.FC<{}> = ({ for (const test of testModel.rootSuite?.allTests() || []) { if (testIds.has(test.id)) { (test as TeleTestCase)._clearResults(); - (test as TeleTestCase)._createTestResult('pending'); + const result = (test as TeleTestCase)._createTestResult('pending'); + (result as any)[statusEx] = 'scheduled'; } } setTestModel({ ...testModel }); @@ -655,9 +656,9 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { lastRunReceiver = undefined; } }, { - pathSeparator, mergeProjects: true, - mergeTestCases: false + mergeTestCases: false, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, }); }, @@ -675,17 +676,19 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); }, - onTestBegin: () => { + onTestBegin: (test: TestCase, testResult: TestResult) => { + (testResult as any)[statusEx] = 'running'; throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); }, - onTestEnd: (test: TestCase) => { + onTestEnd: (test: TestCase, testResult: TestResult) => { if (test.outcome() === 'skipped') ++progress.skipped; else if (test.outcome() === 'unexpected') ++progress.failed; else ++progress.passed; + (testResult as any)[statusEx] = testResult.status; throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); }, @@ -705,9 +708,9 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { onStepBegin: () => {}, onStepEnd: () => {}, }, { - pathSeparator, mergeProjects: true, mergeTestCases: true, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, }); receiver._setClearPreviousResultsWhenTestBegins(); return sendMessage('list', {}); @@ -906,11 +909,11 @@ function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], proje parentGroup.children.push(testCaseItem); } - const result = (test as TeleTestCase).results[0]; + const result = test.results[0]; let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; - if (result?.statusEx === 'scheduled') + if ((result as any)?.[statusEx] === 'scheduled') status = 'scheduled'; - else if (result?.statusEx === 'running') + else if ((result as any)?.[statusEx] === 'running') status = 'running'; else if (result?.status === 'skipped') status = 'skipped'; @@ -1053,3 +1056,4 @@ async function loadSingleTraceFile(url: string): Promise { } const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; +const statusEx = Symbol('statusEx'); From 0feb05cf98a34666e190ca723f7557dbc28f7a4d Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 5 Mar 2024 04:11:37 -0800 Subject: [PATCH 058/141] feat(chromium-tip-of-tree): roll to r1199 (#29817) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index eb427e8df3..dae62ba9af 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1198", + "revision": "1199", "installByDefault": false, - "browserVersion": "124.0.6329.0" + "browserVersion": "124.0.6338.0" }, { "name": "firefox", From abfd2c4e663a2cb79af6122ae8f59cf43f9266ea Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 5 Mar 2024 10:58:55 -0800 Subject: [PATCH 059/141] feat(ui mode): text filter should filter by explicit tags (#29821) Fixes #29815. --- packages/trace-viewer/src/ui/uiModeView.tsx | 4 ++-- tests/playwright-test/ui-mode-test-filters.spec.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index b0c392884f..8e5bb3831b 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -965,8 +965,8 @@ function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map< const filtersStatuses = [...statusFilters.values()].some(Boolean); const filter = (testCase: TestCaseItem) => { - const title = testCase.tests[0].titlePath().join(' ').toLowerCase(); - if (!tokens.every(token => title.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) + const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); + if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) return false; testCase.children = (testCase.children as TestItem[]).filter(test => { return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); diff --git a/tests/playwright-test/ui-mode-test-filters.spec.ts b/tests/playwright-test/ui-mode-test-filters.spec.ts index 3e21392b66..f35d4dec4a 100644 --- a/tests/playwright-test/ui-mode-test-filters.spec.ts +++ b/tests/playwright-test/ui-mode-test-filters.spec.ts @@ -24,7 +24,7 @@ const basicTestTree = { test('passes', () => {}); test('fails', () => { expect(1).toBe(2); }); test.describe('suite', () => { - test('inner passes', () => {}); + test('inner passes', { tag: '@smoke' }, () => {}); test('inner fails', () => { expect(1).toBe(2); }); }); `, @@ -46,6 +46,16 @@ test('should filter by title', async ({ runUITest }) => { `); }); +test('should filter by explicit tags', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + await page.getByPlaceholder('Filter').fill('@smoke inner'); + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ a.test.ts + ▼ ◯ suite + ◯ inner passes + `); +}); + test('should filter by status', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); From 502d21e96a2bfdc5cd461beed09c45aa6ec96a8a Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 5 Mar 2024 22:36:33 +0100 Subject: [PATCH 060/141] docs: use relative links if possible (#29823) --- docs/src/auth.md | 2 +- docs/src/release-notes-csharp.md | 32 ++++++++++++++++---------------- docs/src/release-notes-java.md | 28 ++++++++++++++-------------- docs/src/release-notes-js.md | 32 ++++++++++++++++---------------- docs/src/release-notes-python.md | 28 ++++++++++++++-------------- docs/src/test-runners-python.md | 10 +++++----- 6 files changed, 66 insertions(+), 66 deletions(-) diff --git a/docs/src/auth.md b/docs/src/auth.md index df03d89c60..6600846773 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -256,7 +256,7 @@ existing authentication state instead. Playwright provides a way to reuse the signed-in state in the tests. That way you can log in only once and then skip the log in step for all of the tests. -Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Playwright provides [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) method that can be used to retrieve storage state from authenticated contexts and then create new contexts with pre-populated state. +Web apps use cookie-based or token-based authentication, where authenticated state is stored as [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Playwright provides [`method: BrowserContext.storageState`] method that can be used to retrieve storage state from authenticated contexts and then create new contexts with pre-populated state. Cookies and local storage state can be used across different browsers. They depend on your application's authentication model: some apps might require both cookies and local storage. diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index b562b982bd..9dc037c1a8 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -511,7 +511,7 @@ This version was also tested against the following stable channels: ### New .runsettings file support -`Microsoft.Playwright.NUnit` and `Microsoft.Playwright.MSTest` will now consider the `.runsettings` file and passed settings via the CLI when running end-to-end tests. See in the [documentation](https://playwright.dev/dotnet/docs/test-runners) for a full list of supported settings. +`Microsoft.Playwright.NUnit` and `Microsoft.Playwright.MSTest` will now consider the `.runsettings` file and passed settings via the CLI when running end-to-end tests. See in the [documentation](./test-runners) for a full list of supported settings. The following does now work: @@ -574,7 +574,7 @@ Linux support looks like this: ### New introduction docs -We rewrote our Getting Started docs to be more end-to-end testing focused. Check them out on [playwright.dev](https://playwright.dev/dotnet/docs/intro). +We rewrote our Getting Started docs to be more end-to-end testing focused. Check them out on [playwright.dev](./intro). ## Version 1.23 @@ -952,31 +952,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Page.Mouse.WheelAsync`](https://playwright.dev/dotnet/docs/next/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.AllHeadersAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-all-headers) -- [Request.HeadersArrayAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-headers-array) -- [Request.HeaderValueAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-request#request-header-value) -- [Response.AllHeadersAsync()](https://playwright.dev/dotnet/docs/next/api/class-response#response-all-headers) -- [Response.HeadersArrayAsync()](https://playwright.dev/dotnet/docs/next/api/class-response#response-headers-array) -- [Response.HeaderValueAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-response#response-header-value) -- [Response.HeaderValuesAsync(name: string)](https://playwright.dev/dotnet/docs/next/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/dotnet/docs/next/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.EmulateMediaAsync()](https://playwright.dev/dotnet/docs/next/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.RouteAsync()](https://playwright.dev/dotnet/docs/next/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.SetCheckedAsync(selector: string, checked: Boolean)](https://playwright.dev/dotnet/docs/next/api/class-page#page-set-checked) and [Locator.SetCheckedAsync(selector: string, checked: Boolean)](https://playwright.dev/dotnet/docs/next/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.SizesAsync()](https://playwright.dev/dotnet/docs/next/api/class-request#request-sizes) Returns resource size information for given http request. -- [Tracing.StartChunkAsync()](https://playwright.dev/dotnet/docs/next/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [Tracing.StopChunkAsync()](https://playwright.dev/dotnet/docs/next/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Important ⚠ * ⬆ .NET Core Apps 2.1 are **no longer** supported for our CLI tooling. As of August 31st, 2021, .NET Core 2.1 is no [longer supported](https://devblogs.microsoft.com/dotnet/net-core-2-1-will-reach-end-of-support-on-august-21-2021/) and will not receive any security updates. We've decided to move the CLI forward and require .NET Core 3.1 as a minimum. diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 387dda000a..a792365d21 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -931,31 +931,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Mouse.wheel`](https://playwright.dev/java/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.allHeaders()](https://playwright.dev/java/docs/api/class-request#request-all-headers) -- [Request.headersArray()](https://playwright.dev/java/docs/api/class-request#request-headers-array) -- [Request.headerValue(name: string)](https://playwright.dev/java/docs/api/class-request#request-header-value) -- [Response.allHeaders()](https://playwright.dev/java/docs/api/class-response#response-all-headers) -- [Response.headersArray()](https://playwright.dev/java/docs/api/class-response#response-headers-array) -- [Response.headerValue(name: string)](https://playwright.dev/java/docs/api/class-response#response-header-value) -- [Response.headerValues(name: string)](https://playwright.dev/java/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/java/docs/api/class-browser#browser-new-context-option-color-scheme) or calling [Page.emulateMedia()](https://playwright.dev/java/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.route()](https://playwright.dev/java/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.setChecked(selector: string, checked: boolean)](https://playwright.dev/java/docs/api/class-page#page-set-checked) and [Locator.setChecked(selector: string, checked: boolean)](https://playwright.dev/java/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/java/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [Tracing.startChunk()](https://playwright.dev/java/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [Tracing.stopChunk()](https://playwright.dev/java/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Browser Versions diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 3320a0d14e..6440e91faa 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -1978,31 +1978,31 @@ This version of Playwright was also tested against the following stable channels #### 🖱️ Mouse Wheel -By using [`Page.mouse.wheel`](https://playwright.dev/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. #### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.allHeaders()](https://playwright.dev/docs/api/class-request#request-all-headers) -- [Request.headersArray()](https://playwright.dev/docs/api/class-request#request-headers-array) -- [Request.headerValue(name: string)](https://playwright.dev/docs/api/class-request#request-header-value) -- [Response.allHeaders()](https://playwright.dev/docs/api/class-response#response-all-headers) -- [Response.headersArray()](https://playwright.dev/docs/api/class-response#response-headers-array) -- [Response.headerValue(name: string)](https://playwright.dev/docs/api/class-response#response-header-value) -- [Response.headerValues(name: string)](https://playwright.dev/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] #### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.emulateMedia()](https://playwright.dev/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. #### New APIs -- [Page.route()](https://playwright.dev/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.setChecked(selector: string, checked: boolean)](https://playwright.dev/docs/api/class-page#page-set-checked) and [Locator.setChecked(selector: string, checked: boolean)](https://playwright.dev/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [BrowserContext.tracing.startChunk()](https://playwright.dev/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [BrowserContext.tracing.stopChunk()](https://playwright.dev/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### 🎭 Playwright Test @@ -2017,11 +2017,11 @@ test.describe.parallel('group', () => { }); ``` -By default, tests in a single file are run in order. If you have many independent tests in a single file, you can now run them in parallel with [test.describe.parallel(title, callback)](https://playwright.dev/docs/api/class-test#test-describe-parallel). +By default, tests in a single file are run in order. If you have many independent tests in a single file, you can now run them in parallel with [test.describe.parallel(title, callback)](./api/class-test#test-describe-parallel). #### 🛠 Add `--debug` CLI flag -By using `npx playwright test --debug` it will enable the [Playwright Inspector](https://playwright.dev/docs/debug#playwright-inspector) for you to debug your tests. +By using `npx playwright test --debug` it will enable the [Playwright Inspector](./debug#playwright-inspector) for you to debug your tests. ### Browser Versions diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index cc92290af1..662df326e2 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -1003,31 +1003,31 @@ This version of Playwright was also tested against the following stable channels ### 🖱️ Mouse Wheel -By using [`Page.mouse.wheel`](https://playwright.dev/python/docs/api/class-mouse#mouse-wheel) you are now able to scroll vertically or horizontally. +By using [`method: Mouse.wheel`] you are now able to scroll vertically or horizontally. ### 📜 New Headers API Previously it was not possible to get multiple header values of a response. This is now possible and additional helper functions are available: -- [Request.all_headers()](https://playwright.dev/python/docs/api/class-request#request-all-headers) -- [Request.headers_array()](https://playwright.dev/python/docs/api/class-request#request-headers-array) -- [Request.header_value(name: str)](https://playwright.dev/python/docs/api/class-request#request-header-value) -- [Response.all_headers()](https://playwright.dev/python/docs/api/class-response#response-all-headers) -- [Response.headers_array()](https://playwright.dev/python/docs/api/class-response#response-headers-array) -- [Response.header_value(name: str)](https://playwright.dev/python/docs/api/class-response#response-header-value) -- [Response.header_values(name: str)](https://playwright.dev/python/docs/api/class-response#response-header-values) +- [`method: Request.allHeaders`] +- [`method: Request.headersArray`] +- [`method: Request.headerValue`] +- [`method: Response.allHeaders`] +- [`method: Response.headersArray`] +- [`method: Response.headerValue`] +- [`method: Response.headerValues`] ### 🌈 Forced-Colors emulation -Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [context options](https://playwright.dev/python/docs/api/class-browser#browser-new-context-option-forced-colors) or calling [Page.emulate_media()](https://playwright.dev/python/docs/api/class-page#page-emulate-media). +Its now possible to emulate the `forced-colors` CSS media feature by passing it in the [`method: Browser.newContext`] or calling [`method: Page.emulateMedia`]. ### New APIs -- [Page.route()](https://playwright.dev/python/docs/api/class-page#page-route) accepts new `times` option to specify how many times this route should be matched. -- [Page.set_checked(selector: str, checked: bool)](https://playwright.dev/python/docs/api/class-page#page-set-checked) and [Locator.set_checked(selector: str, checked: bool)](https://playwright.dev/python/docs/api/class-locator#locator-set-checked) was introduced to set the checked state of a checkbox. -- [Request.sizes()](https://playwright.dev/python/docs/api/class-request#request-sizes) Returns resource size information for given http request. -- [BrowserContext.tracing.start_chunk()](https://playwright.dev/python/docs/api/class-tracing#tracing-start-chunk) - Start a new trace chunk. -- [BrowserContext.tracing.stop_chunk()](https://playwright.dev/python/docs/api/class-tracing#tracing-stop-chunk) - Stops a new trace chunk. +- [`method: Page.route`] accepts new `times` option to specify how many times this route should be matched. +- [`method: Page.setChecked`] and [`method: Locator.setChecked`] were introduced to set the checked state of a checkbox. +- [`method: Request.sizes`] Returns resource size information for given http request. +- [`method: Tracing.startChunk`] - Start a new trace chunk. +- [`method: Tracing.stopChunk`] - Stops a new trace chunk. ### Browser Versions diff --git a/docs/src/test-runners-python.md b/docs/src/test-runners-python.md index 402d33987f..0e3ffde0dd 100644 --- a/docs/src/test-runners-python.md +++ b/docs/src/test-runners-python.md @@ -53,14 +53,14 @@ def test_my_app_is_working(fixture_name): **Function scope**: These fixtures are created when requested in a test function and destroyed when the test ends. -- `context`: New [browser context](https://playwright.dev/python/docs/browser-contexts) for a test. -- `page`: New [browser page](https://playwright.dev/python/docs/pages) for a test. +- `context`: New [browser context](./browser-contexts) for a test. +- `page`: New [browser page](./pages) for a test. **Session scope**: These fixtures are created when requested in a test function and destroyed when all tests end. -- `playwright`: [Playwright](https://playwright.dev/python/docs/api/class-playwright) instance. -- `browser_type`: [BrowserType](https://playwright.dev/python/docs/api/class-browsertype) instance of the current browser. -- `browser`: [Browser](https://playwright.dev/python/docs/api/class-browser) instance launched by Playwright. +- `playwright`: [Playwright](./api/class-playwright) instance. +- `browser_type`: [BrowserType](./api/class-browsertype) instance of the current browser. +- `browser`: [Browser](./api/class-browser) instance launched by Playwright. - `browser_name`: Browser name as string. - `browser_channel`: Browser channel as string. - `is_chromium`, `is_webkit`, `is_firefox`: Booleans for the respective browser types. From 8bf8091cb148e4150e4e4a7c5897939091681638 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 5 Mar 2024 15:11:56 -0800 Subject: [PATCH 061/141] chore: extract the tele test tree (#29824) --- .../playwright/src/isomorphic/teleReceiver.ts | 8 +- .../playwright/src/isomorphic/testTree.ts | 316 ++++++++++++ .../trace-viewer/src/ui/teleSuiteUpdater.ts | 137 +++++ packages/trace-viewer/src/ui/uiModeView.tsx | 483 ++---------------- 4 files changed, 509 insertions(+), 435 deletions(-) create mode 100644 packages/playwright/src/isomorphic/testTree.ts create mode 100644 packages/trace-viewer/src/ui/teleSuiteUpdater.ts diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index db573a0f6f..97a2bfa709 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -126,6 +126,7 @@ type TeleReporterReceiverOptions = { mergeTestCases: boolean; resolvePath: (rootDir: string, relativePath: string) => string; configOverrides?: Pick; + clearPreviousResultsWhenTestBegins?: boolean; }; export class TeleReporterReceiver { @@ -135,7 +136,6 @@ export class TeleReporterReceiver { private _tests = new Map(); private _rootDir!: string; private _listOnly = false; - private _clearPreviousResultsWhenTestBegins: boolean = false; private _config!: reporterTypes.FullConfig; constructor(reporter: Partial, options: TeleReporterReceiverOptions) { @@ -189,10 +189,6 @@ export class TeleReporterReceiver { return this._onExit(); } - _setClearPreviousResultsWhenTestBegins() { - this._clearPreviousResultsWhenTestBegins = true; - } - private _onConfigure(config: JsonConfig) { this._rootDir = config.rootDir; this._config = this._parseConfig(config); @@ -234,7 +230,7 @@ export class TeleReporterReceiver { private _onTestBegin(testId: string, payload: JsonTestResultStart) { const test = this._tests.get(testId)!; - if (this._clearPreviousResultsWhenTestBegins) + if (this._options.clearPreviousResultsWhenTestBegins) test._clearResults(); const testResult = test._createTestResult(payload.id); testResult.retry = payload.retry; diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts new file mode 100644 index 0000000000..3c523670f5 --- /dev/null +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -0,0 +1,316 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type TestItemStatus = 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped'; +import type * as reporterTypes from '../../types/testReporter'; + +export type TreeItemBase = { + kind: 'root' | 'group' | 'case' | 'test', + id: string; + title: string; + location: reporterTypes.Location, + duration: number; + parent: TreeItem | undefined; + children: TreeItem[]; + status: TestItemStatus; +}; + +export type GroupItem = TreeItemBase & { + kind: 'group'; + subKind: 'folder' | 'file' | 'describe'; + hasLoadErrors: boolean; + children: (TestCaseItem | GroupItem)[]; +}; + +export type TestCaseItem = TreeItemBase & { + kind: 'case', + tests: reporterTypes.TestCase[]; + children: TestItem[]; +}; + +export type TestItem = TreeItemBase & { + kind: 'test', + test: reporterTypes.TestCase; + project: string; +}; + +export type TreeItem = GroupItem | TestCaseItem | TestItem; + +export class TestTree { + rootItem: GroupItem; + readonly treeItemMap = new Map(); + readonly visibleTestIds = new Set(); + readonly fileNames = new Set(); + + constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map) { + const filterProjects = [...projectFilters.values()].some(Boolean); + this.rootItem = { + kind: 'group', + subKind: 'folder', + id: 'root', + title: '', + location: { file: '', line: 0, column: 0 }, + duration: 0, + parent: undefined, + children: [], + status: 'none', + hasLoadErrors: false, + }; + + const visitSuite = (projectName: string, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => { + for (const suite of parentSuite.suites) { + const title = suite.title || ''; + let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined; + if (!group) { + group = { + kind: 'group', + subKind: 'describe', + id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites + title, + location: suite.location!, + duration: 0, + parent: parentGroup, + children: [], + status: 'none', + hasLoadErrors: false, + }; + parentGroup.children.push(group); + } + visitSuite(projectName, suite, group); + } + + for (const test of parentSuite.tests) { + const title = test.title; + let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; + if (!testCaseItem) { + testCaseItem = { + kind: 'case', + id: 'test:' + test.titlePath().join('\x1e'), + title, + parent: parentGroup, + children: [], + tests: [], + location: test.location, + duration: 0, + status: 'none', + }; + parentGroup.children.push(testCaseItem); + } + + const result = test.results[0]; + let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; + if ((result as any)?.[statusEx] === 'scheduled') + status = 'scheduled'; + else if ((result as any)?.[statusEx] === 'running') + status = 'running'; + else if (result?.status === 'skipped') + status = 'skipped'; + else if (result?.status === 'interrupted') + status = 'none'; + else if (result && test.outcome() !== 'expected') + status = 'failed'; + else if (result && test.outcome() === 'expected') + status = 'passed'; + + testCaseItem.tests.push(test); + testCaseItem.children.push({ + kind: 'test', + id: test.id, + title: projectName, + location: test.location!, + test, + parent: testCaseItem, + children: [], + status, + duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, + project: projectName, + }); + testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0); + } + }; + + const fileMap = new Map(); + for (const projectSuite of rootSuite?.suites || []) { + if (filterProjects && !projectFilters.get(projectSuite.title)) + continue; + for (const fileSuite of projectSuite.suites) { + const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true, fileMap); + visitSuite(projectSuite.title, fileSuite, fileItem); + } + } + + for (const loadError of loadErrors) { + if (!loadError.location) + continue; + const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true, fileMap); + fileItem.hasLoadErrors = true; + } + } + + filterTree(filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { + const tokens = filterText.trim().toLowerCase().split(' '); + const filtersStatuses = [...statusFilters.values()].some(Boolean); + + const filter = (testCase: TestCaseItem) => { + const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); + if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) + return false; + testCase.children = (testCase.children as TestItem[]).filter(test => { + return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); + }); + testCase.tests = (testCase.children as TestItem[]).map(c => c.test); + return !!testCase.children.length; + }; + + const visit = (treeItem: GroupItem) => { + const newChildren: (GroupItem | TestCaseItem)[] = []; + for (const child of treeItem.children) { + if (child.kind === 'case') { + if (filter(child)) + newChildren.push(child); + } else { + visit(child); + if (child.children.length || child.hasLoadErrors) + newChildren.push(child); + } + } + treeItem.children = newChildren; + }; + visit(this.rootItem); + } + + private _fileItem(filePath: string[], isFile: boolean, fileItems: Map): GroupItem { + if (filePath.length === 0) + return this.rootItem; + const fileName = filePath.join(pathSeparator); + const existingFileItem = fileItems.get(fileName); + if (existingFileItem) + return existingFileItem; + const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false, fileItems); + const fileItem: GroupItem = { + kind: 'group', + subKind: isFile ? 'file' : 'folder', + id: fileName, + title: filePath[filePath.length - 1], + location: { file: fileName, line: 0, column: 0 }, + duration: 0, + parent: parentFileItem, + children: [], + status: 'none', + hasLoadErrors: false, + }; + parentFileItem.children.push(fileItem); + fileItems.set(fileName, fileItem); + return fileItem; + } + + sortAndPropagateStatus() { + sortAndPropagateStatus(this.rootItem); + } + + hideOnlyTests() { + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case' && treeItem.children.length === 1) + treeItem.children = []; + else + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + } + + shortenRoot() { + let shortRoot = this.rootItem; + while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') + shortRoot = shortRoot.children[0]; + shortRoot.location = this.rootItem.location; + this.rootItem = shortRoot; + } + + indexTree() { + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'group' && treeItem.location.file) + this.fileNames.add(treeItem.location.file); + if (treeItem.kind === 'case') + treeItem.tests.forEach(t => this.visibleTestIds.add(t.id)); + treeItem.children.forEach(visit); + this.treeItemMap.set(treeItem.id, treeItem); + }; + visit(this.rootItem); + } + + collectTestIds(treeItem?: TreeItem): Set { + const testIds = new Set(); + if (!treeItem) + return testIds; + + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case') + treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); + else if (treeItem.kind === 'test') + testIds.add(treeItem.id); + else + treeItem.children?.forEach(visit); + }; + visit(treeItem); + return testIds; + } + + locationToOpen(treeItem?: TreeItem) { + if (!treeItem) + return; + return treeItem.location.file + ':' + treeItem.location.line; + } +} + +export function sortAndPropagateStatus(treeItem: TreeItem) { + for (const child of treeItem.children) + sortAndPropagateStatus(child); + + if (treeItem.kind === 'group') { + treeItem.children.sort((a, b) => { + const fc = a.location.file.localeCompare(b.location.file); + return fc || a.location.line - b.location.line; + }); + } + + let allPassed = treeItem.children.length > 0; + let allSkipped = treeItem.children.length > 0; + let hasFailed = false; + let hasRunning = false; + let hasScheduled = false; + + for (const child of treeItem.children) { + allSkipped = allSkipped && child.status === 'skipped'; + allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); + hasFailed = hasFailed || child.status === 'failed'; + hasRunning = hasRunning || child.status === 'running'; + hasScheduled = hasScheduled || child.status === 'scheduled'; + } + + if (hasRunning) + treeItem.status = 'running'; + else if (hasScheduled) + treeItem.status = 'scheduled'; + else if (hasFailed) + treeItem.status = 'failed'; + else if (allSkipped) + treeItem.status = 'skipped'; + else if (allPassed) + treeItem.status = 'passed'; +} + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; +export const statusEx = Symbol('statusEx'); diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts new file mode 100644 index 0000000000..50abfbf276 --- /dev/null +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver'; +import { pathSeparator, statusEx } from '@testIsomorphic/testTree'; +import type { ReporterV2 } from 'playwright/src/reporters/reporterV2'; +import type * as reporterTypes from 'playwright/types/testReporter'; + +export type Progress = { + total: number; + passed: number; + failed: number; + skipped: number; +}; + +export type TeleSuiteUpdaterOptions = { + onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void, + onError?: (error: reporterTypes.TestError) => void +}; + +export class TeleSuiteUpdater { + rootSuite: reporterTypes.Suite | undefined; + config: reporterTypes.FullConfig | undefined; + readonly loadErrors: reporterTypes.TestError[] = []; + readonly progress: Progress = { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }; + + private _receiver: TeleReporterReceiver; + private _lastRunReceiver: TeleReporterReceiver | undefined; + private _lastRunTestCount = 0; + private _options: TeleSuiteUpdaterOptions; + + constructor(options: TeleSuiteUpdaterOptions) { + this._receiver = new TeleReporterReceiver(this._createReporter(), { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + clearPreviousResultsWhenTestBegins: true, + }); + this._options = options; + } + + private _createReporter(): ReporterV2 { + return { + version: () => 'v2', + + onConfigure: (c: reporterTypes.FullConfig) => { + this.config = c; + // TeleReportReceiver is merging everything into a single suite, so when we + // run one test, we still get many tests via rootSuite.allTests().length. + // To work around that, have a dedicated per-run receiver that will only have + // suite for a single test run, and hence will have correct total. + this._lastRunReceiver = new TeleReporterReceiver({ + onBegin: (suite: reporterTypes.Suite) => { + this._lastRunTestCount = suite.allTests().length; + this._lastRunReceiver = undefined; + } + }, { + mergeProjects: true, + mergeTestCases: false, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + }); + }, + + onBegin: (suite: reporterTypes.Suite) => { + if (!this.rootSuite) + this.rootSuite = suite; + this.progress.total = this._lastRunTestCount; + this.progress.passed = 0; + this.progress.failed = 0; + this.progress.skipped = 0; + this._options.onUpdate(this, true); + }, + + onEnd: () => { + this._options.onUpdate(this, true); + }, + + onTestBegin: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + (testResult as any)[statusEx] = 'running'; + this._options.onUpdate(this); + }, + + onTestEnd: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + if (test.outcome() === 'skipped') + ++this.progress.skipped; + else if (test.outcome() === 'unexpected') + ++this.progress.failed; + else + ++this.progress.passed; + (testResult as any)[statusEx] = testResult.status; + this._options.onUpdate(this); + }, + + onError: (error: reporterTypes.TestError) => { + this.loadErrors.push(error); + this._options.onError?.(error); + this._options.onUpdate(this); + }, + + printsToStdio: () => { + return false; + }, + + onStdOut: () => {}, + onStdErr: () => {}, + onExit: () => {}, + onStepBegin: () => {}, + onStepEnd: () => {}, + }; + } + + dispatch(mode: 'test' | 'list', message: any) { + // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` + // before we use it. + if (mode === 'test') + this._lastRunReceiver?.dispatch('test', message)?.catch(() => {}); + this._receiver.dispatch(mode, message)?.catch(() => {}); + } +} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 8e5bb3831b..164167f272 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -20,9 +20,11 @@ import '@web/common.css'; import React from 'react'; import { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; -import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { baseFullConfig, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { TeleSuiteUpdater } from './teleSuiteUpdater'; +import type { Progress } from './teleSuiteUpdater'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; -import type { FullConfig, Suite, TestCase, Location, TestError, TestResult } from 'playwright/types/testReporter'; +import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; import { idForAction, MultiTraceModel } from './modelUtil'; import type { SourceLocation } from './modelUtil'; @@ -38,10 +40,11 @@ import { artifactsFolderName } from '@testIsomorphic/folders'; import { msToString, settings, useSetting } from '@web/uiUtils'; import type { ActionTraceEvent } from '@trace/trace'; import { connect } from './wsPort'; +import { statusEx, TestTree } from '@testIsomorphic/testTree'; +import type { TreeItem } from '@testIsomorphic/testTree'; import { testStatusIcon } from './testUtils'; -import type { UITestStatus } from './testUtils'; -let updateRootSuite: (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress | undefined) => void = () => {}; +let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {}; let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; @@ -58,9 +61,9 @@ const xtermDataSource: XtermDataSource = { }; type TestModel = { - config: FullConfig | undefined; - rootSuite: Suite | undefined; - loadErrors: TestError[]; + config: reporterTypes.FullConfig | undefined; + rootSuite: reporterTypes.Suite | undefined; + loadErrors: reporterTypes.TestError[]; }; export const UIModeView: React.FC<{}> = ({ @@ -76,7 +79,7 @@ export const UIModeView: React.FC<{}> = ({ const [projectFilters, setProjectFilters] = React.useState>(new Map()); const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined, loadErrors: [] }); const [progress, setProgress] = React.useState(); - const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }>({}); + const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }>({}); const [visibleTestIds, setVisibleTestIds] = React.useState>(new Set()); const [isLoading, setIsLoading] = React.useState(false); const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); @@ -94,7 +97,7 @@ export const UIModeView: React.FC<{}> = ({ setIsLoading(true); setWatchedTreeIds({ value: new Set() }); updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined); - refreshRootSuite(true).then(async () => { + refreshRootSuite().then(async () => { setIsLoading(false); const { hasBrowsers } = await sendMessage('checkBrowsers'); setHasBrowsers(hasBrowsers); @@ -114,7 +117,7 @@ export const UIModeView: React.FC<{}> = ({ }); }, [reloadTests]); - updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => { + updateRootSuite = React.useCallback((config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], newProgress: Progress | undefined) => { const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; for (const projectName of projectFilters.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) @@ -367,7 +370,7 @@ const TestList: React.FC<{ setWatchedTreeIds: (ids: { value: Set }) => void, isLoading?: boolean, setVisibleTestIds: (testIds: Set) => void, - onItemSelected: (item: { treeItem?: TreeItem, testCase?: TestCase, testFile?: SourceLocation }) => void, + onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, requestedCollapseAllCount: number, }> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); @@ -375,27 +378,15 @@ const TestList: React.FC<{ const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); // Build the test tree. - const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { - let rootItem = createTree(testModel.rootSuite, testModel.loadErrors, projectFilters); - filterTree(rootItem, filterText, statusFilters, runningState?.testIds); - sortAndPropagateStatus(rootItem); - rootItem = shortenRoot(rootItem); - - hideOnlyTests(rootItem); - const treeItemMap = new Map(); - const visibleTestIds = new Set(); - const fileNames = new Set(); - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'group' && treeItem.location.file) - fileNames.add(treeItem.location.file); - if (treeItem.kind === 'case') - treeItem.tests.forEach(t => visibleTestIds.add(t.id)); - treeItem.children.forEach(visit); - treeItemMap.set(treeItem.id, treeItem); - }; - visit(rootItem); - setVisibleTestIds(visibleTestIds); - return { rootItem, treeItemMap, fileNames }; + const { testTree } = React.useMemo(() => { + const testTree = new TestTree(testModel.rootSuite, testModel.loadErrors, projectFilters); + testTree.filterTree(filterText, statusFilters, runningState?.testIds); + testTree.sortAndPropagateStatus(); + testTree.shortenRoot(); + testTree.hideOnlyTests(); + testTree.indexTree(); + setVisibleTestIds(testTree.visibleTestIds); + return { testTree }; }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); // Look for a first failure within the run batch to select it. @@ -403,7 +394,7 @@ const TestList: React.FC<{ // If collapse was requested, clear the expanded items and return w/o selected item. if (collapseAllCount !== requestedCollapseAllCount) { treeState.expandedItems.clear(); - for (const item of treeItemMap.keys()) + for (const item of testTree.treeItemMap.keys()) treeState.expandedItems.set(item, false); setCollapseAllCount(requestedCollapseAllCount); setSelectedTreeItemId(undefined); @@ -425,15 +416,15 @@ const TestList: React.FC<{ selectedTreeItem = treeItem; } }; - visit(rootItem); + visit(testTree.rootItem); if (selectedTreeItem) setSelectedTreeItemId(selectedTreeItem.id); - }, [runningState, setSelectedTreeItemId, rootItem, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState, treeItemMap]); + }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { - const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; + const selectedTreeItem = selectedTreeItemId ? testTree.treeItemMap.get(selectedTreeItemId) : undefined; let testFile: SourceLocation | undefined; if (selectedTreeItem) { testFile = { @@ -445,36 +436,36 @@ const TestList: React.FC<{ } }; } - let selectedTest: TestCase | undefined; + let selectedTest: reporterTypes.TestCase | undefined; if (selectedTreeItem?.kind === 'test') selectedTest = selectedTreeItem.test; else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) selectedTest = selectedTreeItem.tests[0]; onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); return { selectedTreeItem }; - }, [onItemSelected, selectedTreeItemId, testModel, treeItemMap]); + }, [onItemSelected, selectedTreeItemId, testModel, testTree]); // Update watch all. React.useEffect(() => { if (isLoading) return; if (watchAll) { - sendMessageNoReply('watch', { fileNames: [...fileNames] }); + sendMessageNoReply('watch', { fileNames: [...testTree.fileNames] }); } else { const fileNames = new Set(); for (const itemId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(itemId); + const treeItem = testTree.treeItemMap.get(itemId); const fileName = treeItem?.location.file; if (fileName) fileNames.add(fileName); } sendMessageNoReply('watch', { fileNames: [...fileNames] }); } - }, [isLoading, rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); + }, [isLoading, testTree, watchAll, watchedTreeIds]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); - runTests('bounce-if-busy', collectTestIds(treeItem)); + runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); }; runWatchedTests = (changedTestFiles: string[]) => { @@ -484,17 +475,17 @@ const TestList: React.FC<{ const visit = (treeItem: TreeItem) => { const fileName = treeItem.location.file; if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); + testIds.push(...testTree.collectTestIds(treeItem)); if (treeItem.kind === 'group' && treeItem.subKind === 'folder') treeItem.children.forEach(visit); }; - visit(rootItem); + visit(testTree.rootItem); } else { for (const treeId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(treeId); + const treeItem = testTree.treeItemMap.get(treeId); const fileName = treeItem?.location.file; if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); + testIds.push(...testTree.collectTestIds(treeItem)); } } runTests('queue-if-busy', new Set(testIds)); @@ -504,7 +495,7 @@ const TestList: React.FC<{ name='tests' treeState={treeState} setTreeState={setTreeState} - rootItem={rootItem} + rootItem={testTree.rootItem} dataTestId='test-tree' render={treeItem => { return
@@ -512,7 +503,7 @@ const TestList: React.FC<{ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} runTreeItem(treeItem)} disabled={!!runningState}> - sendMessageNoReply('open', { location: locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + sendMessageNoReply('open', { location: testTree.locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) watchedTreeIds.value.delete(treeItem.id); @@ -537,7 +528,7 @@ const TestList: React.FC<{ }; const TraceView: React.FC<{ - item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }, + item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, rootDir?: string, }> = ({ item, rootDir }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); @@ -608,19 +599,17 @@ const TraceView: React.FC<{ status={item.treeItem?.status} />; }; -let receiver: TeleReporterReceiver | undefined; -let lastRunReceiver: TeleReporterReceiver | undefined; -let lastRunTestCount: number; +let teleSuiteUpdater: TeleSuiteUpdater | undefined; let throttleTimer: NodeJS.Timeout | undefined; -let throttleData: { config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress } | undefined; +let throttleData: { config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress } | undefined; const throttledAction = () => { clearTimeout(throttleTimer); throttleTimer = undefined; updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress); }; -const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress, immediate = false) => { +const throttleUpdateRootSuite = (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress, immediate = false) => { throttleData = { config, rootSuite, loadErrors, progress }; if (immediate) throttledAction(); @@ -628,91 +617,15 @@ const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadError throttleTimer = setTimeout(throttledAction, 250); }; -const refreshRootSuite = (eraseResults: boolean): Promise => { - if (!eraseResults) - return sendMessage('list', {}); - - let rootSuite: Suite; - const loadErrors: TestError[] = []; - const progress: Progress = { - total: 0, - passed: 0, - failed: 0, - skipped: 0, - }; - let config: FullConfig; - receiver = new TeleReporterReceiver({ - version: () => 'v2', - - onConfigure: (c: FullConfig) => { - config = c; - // TeleReportReceiver is merging everything into a single suite, so when we - // run one test, we still get many tests via rootSuite.allTests().length. - // To work around that, have a dedicated per-run receiver that will only have - // suite for a single test run, and hence will have correct total. - lastRunReceiver = new TeleReporterReceiver({ - onBegin: (suite: Suite) => { - lastRunTestCount = suite.allTests().length; - lastRunReceiver = undefined; - } - }, { - mergeProjects: true, - mergeTestCases: false, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, - }); +const refreshRootSuite = (): Promise => { + teleSuiteUpdater = new TeleSuiteUpdater({ + onUpdate: (source, immediate) => { + throttleUpdateRootSuite(source.config!, source.rootSuite || new TeleSuite('', 'root'), source.loadErrors, source.progress, immediate); }, - - onBegin: (suite: Suite) => { - if (!rootSuite) - rootSuite = suite; - progress.total = lastRunTestCount; - progress.passed = 0; - progress.failed = 0; - progress.skipped = 0; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onEnd: () => { - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onTestBegin: (test: TestCase, testResult: TestResult) => { - (testResult as any)[statusEx] = 'running'; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onTestEnd: (test: TestCase, testResult: TestResult) => { - if (test.outcome() === 'skipped') - ++progress.skipped; - else if (test.outcome() === 'unexpected') - ++progress.failed; - else - ++progress.passed; - (testResult as any)[statusEx] = testResult.status; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onError: (error: TestError) => { + onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); - loadErrors.push(error); - throttleUpdateRootSuite(config, rootSuite ?? new TeleSuite('', 'root'), loadErrors, progress); }, - - printsToStdio: () => { - return false; - }, - - onStdOut: () => {}, - onStdErr: () => {}, - onExit: () => {}, - onStepBegin: () => {}, - onStepEnd: () => {}, - }, { - mergeProjects: true, - mergeTestCases: true, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, }); - receiver._setClearPreviousResultsWhenTestBegins(); return sendMessage('list', {}); }; @@ -729,7 +642,7 @@ const sendMessageNoReply = (method: string, params?: any) => { const dispatchEvent = (method: string, params?: any) => { if (method === 'listChanged') { - refreshRootSuite(false).catch(() => {}); + sendMessage('list', {}).catch(() => {}); return; } @@ -749,304 +662,19 @@ const dispatchEvent = (method: string, params?: any) => { } if (method === 'listReport') - receiver?.dispatch('list', params)?.catch(() => {}); - - if (method === 'testReport') { - // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` - // before we use it. - lastRunReceiver?.dispatch('test', params)?.catch(() => {}); - receiver?.dispatch('test', params)?.catch(() => {}); - } + teleSuiteUpdater?.dispatch('list', params); + if (method === 'testReport') + teleSuiteUpdater?.dispatch('test', params); }; -const outputDirForTestCase = (testCase: TestCase): string | undefined => { - for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) { +const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { + for (let suite: reporterTypes.Suite | undefined = testCase.parent; suite; suite = suite.parent) { if (suite.project()) return suite.project()?.outputDir; } return undefined; }; -const locationToOpen = (treeItem?: TreeItem) => { - if (!treeItem) - return; - return treeItem.location.file + ':' + treeItem.location.line; -}; - -const collectTestIds = (treeItem?: TreeItem): Set => { - const testIds = new Set(); - if (!treeItem) - return testIds; - - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case') - treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); - else if (treeItem.kind === 'test') - testIds.add(treeItem.id); - else - treeItem.children?.forEach(visit); - }; - visit(treeItem); - return testIds; -}; - -type Progress = { - total: number; - passed: number; - failed: number; - skipped: number; -}; - -type TreeItemBase = { - kind: 'root' | 'group' | 'case' | 'test', - id: string; - title: string; - location: Location, - duration: number; - parent: TreeItem | undefined; - children: TreeItem[]; - status: UITestStatus; -}; - -type GroupItem = TreeItemBase & { - kind: 'group'; - subKind: 'folder' | 'file' | 'describe'; - hasLoadErrors: boolean; - children: (TestCaseItem | GroupItem)[]; -}; - -type TestCaseItem = TreeItemBase & { - kind: 'case', - tests: TestCase[]; - children: TestItem[]; -}; - -type TestItem = TreeItemBase & { - kind: 'test', - test: TestCase; - project: string; -}; - -type TreeItem = GroupItem | TestCaseItem | TestItem; - -function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, fileItems: Map): GroupItem { - if (filePath.length === 0) - return rootItem; - const fileName = filePath.join(pathSeparator); - const existingFileItem = fileItems.get(fileName); - if (existingFileItem) - return existingFileItem; - const parentFileItem = getFileItem(rootItem, filePath.slice(0, filePath.length - 1), false, fileItems); - const fileItem: GroupItem = { - kind: 'group', - subKind: isFile ? 'file' : 'folder', - id: fileName, - title: filePath[filePath.length - 1], - location: { file: fileName, line: 0, column: 0 }, - duration: 0, - parent: parentFileItem, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentFileItem.children.push(fileItem); - fileItems.set(fileName, fileItem); - return fileItem; -} - -function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], projectFilters: Map): GroupItem { - const filterProjects = [...projectFilters.values()].some(Boolean); - const rootItem: GroupItem = { - kind: 'group', - subKind: 'folder', - id: 'root', - title: '', - location: { file: '', line: 0, column: 0 }, - duration: 0, - parent: undefined, - children: [], - status: 'none', - hasLoadErrors: false, - }; - - const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => { - for (const suite of parentSuite.suites) { - const title = suite.title || ''; - let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined; - if (!group) { - group = { - kind: 'group', - subKind: 'describe', - id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites - title, - location: suite.location!, - duration: 0, - parent: parentGroup, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentGroup.children.push(group); - } - visitSuite(projectName, suite, group); - } - - for (const test of parentSuite.tests) { - const title = test.title; - let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; - if (!testCaseItem) { - testCaseItem = { - kind: 'case', - id: 'test:' + test.titlePath().join('\x1e'), - title, - parent: parentGroup, - children: [], - tests: [], - location: test.location, - duration: 0, - status: 'none', - }; - parentGroup.children.push(testCaseItem); - } - - const result = test.results[0]; - let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; - if ((result as any)?.[statusEx] === 'scheduled') - status = 'scheduled'; - else if ((result as any)?.[statusEx] === 'running') - status = 'running'; - else if (result?.status === 'skipped') - status = 'skipped'; - else if (result?.status === 'interrupted') - status = 'none'; - else if (result && test.outcome() !== 'expected') - status = 'failed'; - else if (result && test.outcome() === 'expected') - status = 'passed'; - - testCaseItem.tests.push(test); - testCaseItem.children.push({ - kind: 'test', - id: test.id, - title: projectName, - location: test.location!, - test, - parent: testCaseItem, - children: [], - status, - duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, - project: projectName, - }); - testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0); - } - }; - - const fileMap = new Map(); - for (const projectSuite of rootSuite?.suites || []) { - if (filterProjects && !projectFilters.get(projectSuite.title)) - continue; - for (const fileSuite of projectSuite.suites) { - const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap); - visitSuite(projectSuite.title, fileSuite, fileItem); - } - } - - for (const loadError of loadErrors) { - if (!loadError.location) - continue; - const fileItem = getFileItem(rootItem, loadError.location.file.split(pathSeparator), true, fileMap); - fileItem.hasLoadErrors = true; - } - return rootItem; -} - -function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { - const tokens = filterText.trim().toLowerCase().split(' '); - const filtersStatuses = [...statusFilters.values()].some(Boolean); - - const filter = (testCase: TestCaseItem) => { - const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); - if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) - return false; - testCase.children = (testCase.children as TestItem[]).filter(test => { - return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); - }); - testCase.tests = (testCase.children as TestItem[]).map(c => c.test); - return !!testCase.children.length; - }; - - const visit = (treeItem: GroupItem) => { - const newChildren: (GroupItem | TestCaseItem)[] = []; - for (const child of treeItem.children) { - if (child.kind === 'case') { - if (filter(child)) - newChildren.push(child); - } else { - visit(child); - if (child.children.length || child.hasLoadErrors) - newChildren.push(child); - } - } - treeItem.children = newChildren; - }; - visit(rootItem); -} - -function sortAndPropagateStatus(treeItem: TreeItem) { - for (const child of treeItem.children) - sortAndPropagateStatus(child); - - if (treeItem.kind === 'group') { - treeItem.children.sort((a, b) => { - const fc = a.location.file.localeCompare(b.location.file); - return fc || a.location.line - b.location.line; - }); - } - - let allPassed = treeItem.children.length > 0; - let allSkipped = treeItem.children.length > 0; - let hasFailed = false; - let hasRunning = false; - let hasScheduled = false; - - for (const child of treeItem.children) { - allSkipped = allSkipped && child.status === 'skipped'; - allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); - hasFailed = hasFailed || child.status === 'failed'; - hasRunning = hasRunning || child.status === 'running'; - hasScheduled = hasScheduled || child.status === 'scheduled'; - } - - if (hasRunning) - treeItem.status = 'running'; - else if (hasScheduled) - treeItem.status = 'scheduled'; - else if (hasFailed) - treeItem.status = 'failed'; - else if (allSkipped) - treeItem.status = 'skipped'; - else if (allPassed) - treeItem.status = 'passed'; -} - -function shortenRoot(rootItem: GroupItem): GroupItem { - let shortRoot = rootItem; - while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') - shortRoot = shortRoot.children[0]; - shortRoot.location = rootItem.location; - return shortRoot; -} - -function hideOnlyTests(rootItem: GroupItem) { - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case' && treeItem.children.length === 1) - treeItem.children = []; - else - treeItem.children.forEach(visit); - }; - visit(rootItem); -} - async function loadSingleTraceFile(url: string): Promise { const params = new URLSearchParams(); params.set('trace', url); @@ -1054,6 +682,3 @@ async function loadSingleTraceFile(url: string): Promise { const contextEntries = await response.json() as ContextEntry[]; return new MultiTraceModel(contextEntries); } - -const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; -const statusEx = Symbol('statusEx'); From a3ed799cd511a44163f401d9247041f6a1c3c80c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 5 Mar 2024 16:34:39 -0800 Subject: [PATCH 062/141] fix(tsconfig): when extending, retain pathsBasePath from the original config (#29822) This fixes a case where we incorrectly used the final config's base path when resolving relative path mappings in the absence of the baseUrl. Fixes #29816. --- .../src/third_party/tsconfig-loader.ts | 39 +++++++++++-------- .../playwright/src/transform/transform.ts | 13 +++---- tests/playwright-test/resolver.spec.ts | 37 +++++++++++++++++- 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index d14b7602b0..d85ff32100 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -44,8 +44,11 @@ interface TsConfig { export interface LoadedTsConfig { tsConfigPath: string; - baseUrl?: string; - paths?: { [key: string]: Array }; + paths?: { + mapping: { [key: string]: Array }; + pathsBasePath: string; // absolute path + }; + absoluteBaseUrl?: string; allowJs?: boolean; } @@ -132,25 +135,27 @@ function loadTsConfig( for (const extendedConfig of extendsArray) { const extendedConfigPath = resolveConfigFile(configFilePath, extendedConfig); const base = loadTsConfig(extendedConfigPath, references, visited); - - // baseUrl should be interpreted as relative to the base tsconfig, - // but we need to update it so it is relative to the original tsconfig being loaded - if (base.baseUrl && base.baseUrl) { - const extendsDir = path.dirname(extendedConfig); - base.baseUrl = path.join(extendsDir, base.baseUrl); - } // Retain result instance, so that caching works. Object.assign(result, base, { tsConfigPath: configFilePath }); } - const loadedConfig = Object.fromEntries(Object.entries({ - baseUrl: parsedConfig.compilerOptions?.baseUrl, - paths: parsedConfig.compilerOptions?.paths, - allowJs: parsedConfig?.compilerOptions?.allowJs, - }).filter(([, value]) => value !== undefined)); - - // Retain result instance, so that caching works. - Object.assign(result, loadedConfig); + if (parsedConfig.compilerOptions?.allowJs !== undefined) + result.allowJs = parsedConfig.compilerOptions.allowJs; + if (parsedConfig.compilerOptions?.paths !== undefined) { + // We must store pathsBasePath from the config that defines "paths" and later resolve + // based on this absolute path, when no "baseUrl" is specified. See tsc for reference: + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/commandLineParser.ts#L3129 + // https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/moduleSpecifiers.ts#L510 + result.paths = { + mapping: parsedConfig.compilerOptions.paths, + pathsBasePath: path.dirname(configFilePath), + }; + } + if (parsedConfig.compilerOptions?.baseUrl !== undefined) { + // Follow tsc and resolve all relative file paths in the config right away. + // This way it is safe to inherit paths between the configs. + result.absoluteBaseUrl = path.resolve(path.dirname(configFilePath), parsedConfig.compilerOptions.baseUrl); + } for (const ref of parsedConfig.references || []) references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited)); diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 82b5acdda1..a2e376d043 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -30,7 +30,7 @@ import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules const version = require('../../package.json').version; type ParsedTsConfigData = { - absoluteBaseUrl: string; + pathsBase?: string; paths: { key: string, values: string[] }[]; allowJs: boolean; }; @@ -58,16 +58,15 @@ export function transformConfig(): TransformConfig { } function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { - // Make 'baseUrl' absolute, because it is relative to the tsconfig.json, not to cwd. // When no explicit baseUrl is set, resolve paths relative to the tsconfig file. // See https://www.typescriptlang.org/tsconfig#paths - const absoluteBaseUrl = path.resolve(path.dirname(tsconfig.tsConfigPath), tsconfig.baseUrl ?? '.'); + const pathsBase = tsconfig.absoluteBaseUrl ?? tsconfig.paths?.pathsBasePath; // Only add the catch-all mapping when baseUrl is specified - const pathsFallback = tsconfig.baseUrl ? [{ key: '*', values: ['*'] }] : []; + const pathsFallback = tsconfig.absoluteBaseUrl ? [{ key: '*', values: ['*'] }] : []; return { allowJs: !!tsconfig.allowJs, - absoluteBaseUrl, - paths: Object.entries(tsconfig.paths || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) + pathsBase, + paths: Object.entries(tsconfig.paths?.mapping || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback) }; } @@ -132,7 +131,7 @@ export function resolveHook(filename: string, specifier: string): string | undef let candidate = value; if (value.includes('*')) candidate = candidate.replace('*', matchedPartOfSpecifier); - candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate); + candidate = path.resolve(tsconfig.pathsBase!, candidate); const existing = resolveImportSpecifierExtension(candidate); if (existing) { longestPrefixLength = keyPrefix.length; diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 778efefe1f..4092263648 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -505,6 +505,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }`, 'tsconfig.base1.json': `{ "extends": "./tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + }, }`, 'tsconfig.base2.json': `{ "compilerOptions": { @@ -518,7 +521,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { }, }, }`, - 'a.test.ts': ` + 'a.test.js': ` + // This js file is affected by tsconfig because allowJs is inherited. + // Next line resolve to the final baseUrl ("dir") + relative path mapping ("./foo/bar/util/*"). const { foo } = require('util/file'); import { test, expect } from '@playwright/test'; test('test', ({}, testInfo) => { @@ -534,6 +539,36 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); }); +test('should resolve paths relative to the originating config when extending and no baseUrl', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'tsconfig.json': `{ + "extends": ["./dir/tsconfig.base.json"], + }`, + 'dir/tsconfig.base.json': `{ + "compilerOptions": { + "paths": { + "~/*": ["../mapped/*"], + }, + }, + }`, + 'a.test.ts': ` + // This resolves relative to the base tsconfig that defined path mapping, + // because there is no baseUrl in the final tsconfig. + const { foo } = require('~/file'); + import { test, expect } from '@playwright/test'; + test('test', ({}, testInfo) => { + expect(foo).toBe('foo'); + }); + `, + 'mapped/file.ts': ` + module.exports = { foo: 'foo' }; + `, + }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); +}); + test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => { const result = await runInlineTest({ 'app/pkg/main.ts': ` From 1d4bdc6898b894fe80c3c2fa2040eb49ffb48f57 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 5 Mar 2024 16:35:11 -0800 Subject: [PATCH 063/141] chore(test runner): make runAsStage throw and catch errors explicitly (#29814) --- .../playwright/src/worker/fixtureRunner.ts | 43 +++-- packages/playwright/src/worker/testInfo.ts | 108 ++++------- .../playwright/src/worker/timeoutManager.ts | 16 +- packages/playwright/src/worker/workerMain.ts | 169 ++++++++++++------ tests/playwright-test/fixture-errors.spec.ts | 29 ++- 5 files changed, 217 insertions(+), 148 deletions(-) diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 7405a83cab..8036bee289 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -17,7 +17,7 @@ import { formatLocation, filterStackFile } from '../util'; import { ManualPromise } from 'playwright-core/lib/utils'; import type { TestInfoImpl } from './testInfo'; -import type { FixtureDescription } from './timeoutManager'; +import { TimeoutManagerError, type FixtureDescription } from './timeoutManager'; import { fixtureParameterNames, type FixturePool, type FixtureRegistration, type FixtureScope } from '../common/fixtures'; import type { WorkerInfo } from '../../types/test'; import type { Location } from '../../types/testReporter'; @@ -156,12 +156,16 @@ class Fixture { await this._selfTeardownComplete; } } finally { - for (const dep of this._deps) - dep._usages.delete(this); - this.runner.instanceForId.delete(this.registration.id); + this._cleanupInstance(); } } + _cleanupInstance() { + for (const dep of this._deps) + dep._usages.delete(this); + this.runner.instanceForId.delete(this.registration.id); + } + _collectFixturesInTeardownOrder(scope: FixtureScope, collector: Set) { if (this.registration.scope !== scope) return; @@ -201,17 +205,36 @@ export class FixtureRunner { } async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { + if (scope === 'worker') { + const collector = new Set(); + for (const fixture of this.instanceForId.values()) + fixture._collectFixturesInTeardownOrder('test', collector); + // Clean up test-scoped fixtures that did not teardown because of timeout in one of them. + // This preserves fixture integrity for worker fixtures. + for (const fixture of collector) + fixture._cleanupInstance(); + this.testScopeClean = true; + } + // Teardown fixtures in the reverse order. const fixtures = Array.from(this.instanceForId.values()).reverse(); const collector = new Set(); for (const fixture of fixtures) fixture._collectFixturesInTeardownOrder(scope, collector); - await testInfo._runAsStage({ title: `teardown ${scope} scope` }, async () => { - for (const fixture of collector) + let firstError: Error | undefined; + for (const fixture of collector) { + try { await fixture.teardown(testInfo); - }); + } catch (error) { + if (error instanceof TimeoutManagerError) + throw error; + firstError = firstError ?? error; + } + } if (scope === 'test') this.testScopeClean = true; + if (firstError) + throw firstError; } async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only'): Promise { @@ -238,10 +261,8 @@ export class FixtureRunner { this._collectFixturesInSetupOrder(this.pool!.resolve(name)!, collector); // Setup fixtures. - await testInfo._runAsStage({ title: 'setup fixtures', stopOnChildError: true }, async () => { - for (const registration of collector) - await this._setupFixtureForRegistration(registration, testInfo); - }); + for (const registration of collector) + await this._setupFixtureForRegistration(registration, testInfo); // Create params object. const params: { [key: string]: any } = {}; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 792cb3d096..70880af1c2 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -20,7 +20,7 @@ import { MaxTime, captureRawStack, monotonicTime, zones, sanitizeForFilePath, st import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; -import { TimeoutManager } from './timeoutManager'; +import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; import type { RunnableDescription, RunnableType, TimeSlot } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; @@ -56,13 +56,7 @@ export type TestStage = { runnableSlot?: TimeSlot; canTimeout?: boolean; allowSkip?: boolean; - stopOnChildError?: boolean; - continueOnChildTimeout?: boolean; - step?: TestStepInternal; - error?: Error; - triggeredSkip?: boolean; - triggeredTimeout?: boolean; }; export class TestInfoImpl implements TestInfo { @@ -352,13 +346,6 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } - _unhandledError(error: Error) { - this._failWithError(error, true /* isHardError */, true /* retriable */); - const stage = this._stages[this._stages.length - 1]; - if (stage) - stage.error = stage.error ?? error; - } - _failWithError(error: Error, isHardError: boolean, retriable: boolean) { if (!retriable) this._hasNonRetriableError = true; @@ -385,22 +372,6 @@ export class TestInfoImpl implements TestInfo { const parent = this._stages[this._stages.length - 1]; stage.allowSkip = stage.allowSkip ?? parent?.allowSkip ?? false; - if (parent?.allowSkip && parent?.triggeredSkip) { - // Do not run more child steps after "skip" has been triggered. - debugTest(`ignored stage "${stage.title}" after previous skip`); - return; - } - if (parent?.stopOnChildError && parent?.error) { - // Do not run more child steps after a previous one failed. - debugTest(`ignored stage "${stage.title}" after previous error`); - return; - } - if (parent?.triggeredTimeout && !parent?.continueOnChildTimeout) { - // Do not run more child steps after a previous one timed out. - debugTest(`ignored stage "${stage.title}" after previous timeout`); - return; - } - if (debugTest.enabled) { const location = stage.location ? ` at "${formatLocation(stage.location)}"` : ``; debugTest(`started stage "${stage.title}"${location}`); @@ -422,49 +393,44 @@ export class TestInfoImpl implements TestInfo { } } - const timeoutError = await this._timeoutManager.withRunnable(runnable, async () => { - try { - await cb(); - } catch (e) { - if (stage.allowSkip && (e instanceof SkipError)) { - stage.triggeredSkip = true; - if (this.status === 'passed') - this.status = 'skipped'; - } else { - // Prefer the first error. - stage.error = stage.error ?? e; - this._failWithError(e, true /* isHardError */, true /* retriable */); + try { + await this._timeoutManager.withRunnable(runnable, async () => { + // Note: separate try/catch is here to report errors after timeout. + // This way we get a nice "locator.click" error after the test times out and closes the page. + try { + await cb(); + } catch (e) { + if (stage.allowSkip && (e instanceof SkipError)) { + if (this.status === 'passed') + this.status = 'skipped'; + } else if (!(e instanceof TimeoutManagerError)) { + // Do not consider timeout errors in child stages as a regular "hard error". + this._failWithError(e, true /* isHardError */, true /* retriable */); + } + throw e; } + }); + stage.step?.complete({}); + } catch (error) { + // When interrupting, we arrive here with a TimeoutManagerError, but we should not + // consider it a timeout. + if (!this._wasInterrupted && !this._didTimeout && (error instanceof TimeoutManagerError)) { + this._didTimeout = true; + const serialized = serializeError(error); + this.errors.push(serialized); + this._tracing.appendForError(serialized); + // Do not overwrite existing failure upon hook/teardown timeout. + if (this.status === 'passed' || this.status === 'skipped') + this.status = 'timedOut'; } - }); - if (timeoutError) - stage.triggeredTimeout = true; - - // When interrupting, we arrive here with a timeoutError, but we should not - // consider it a timeout. - if (!this._wasInterrupted && !this._didTimeout && timeoutError) { - stage.error = stage.error ?? timeoutError; - this._didTimeout = true; - const serialized = serializeError(timeoutError); - this.errors.push(serialized); - this._tracing.appendForError(serialized); - // Do not overwrite existing failure upon hook/teardown timeout. - if (this.status === 'passed' || this.status === 'skipped') - this.status = 'timedOut'; + stage.step?.complete({ error }); + throw error; + } finally { + if (this._stages[this._stages.length - 1] !== stage) + throw new Error(`Internal error: inconsistent stages!`); + this._stages.pop(); + debugTest(`finished stage "${stage.title}"`); } - - if (parent) { - // Notify parent about child error, skip and timeout. - parent.error = parent.error ?? stage.error; - parent.triggeredSkip = parent.triggeredSkip || stage.triggeredSkip; - parent.triggeredTimeout = parent.triggeredTimeout || stage.triggeredTimeout; - } - - if (this._stages[this._stages.length - 1] !== stage) - throw new Error(`Internal error: inconsistent stages!`); - this._stages.pop(); - stage.step?.complete({ error: stage.error }); - debugTest(`finished stage "${stage.title}"`); } _isFailure() { @@ -557,7 +523,7 @@ export class TestInfoImpl implements TestInfo { } } -class SkipError extends Error { +export class SkipError extends Error { } const stepSymbol = Symbol('step'); diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index a4dc7963ca..d90ee06547 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -56,22 +56,20 @@ export class TimeoutManager { this._timeoutRunner.interrupt(); } - async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { - if (!runnable) { - await cb(); - return; - } + async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { + if (!runnable) + return await cb(); const existingRunnable = this._runnable; const effectiveRunnable = { ...runnable }; if (!effectiveRunnable.slot) effectiveRunnable.slot = this._runnable.slot; this._updateRunnables(effectiveRunnable, undefined); try { - await this._timeoutRunner.run(cb); + return await this._timeoutRunner.run(cb); } catch (error) { if (!(error instanceof TimeoutRunnerError)) throw error; - return this._createTimeoutError(); + throw this._createTimeoutError(); } finally { this._updateRunnables(existingRunnable, undefined); } @@ -171,10 +169,12 @@ export class TimeoutManager { message = `Fixture "${fixtureWithSlot.title}" timeout of ${timeout}ms exceeded during ${fixtureWithSlot.phase}.`; message = colors.red(message); const location = (fixtureWithSlot || this._runnable).location; - const error = new Error(message); + const error = new TimeoutManagerError(message); error.name = ''; // Include location for hooks, modifiers and fixtures to distinguish between them. error.stack = message + (location ? `\n at ${location.file}:${location.line}:${location.column}` : ''); return error; } } + +export class TimeoutManagerError extends Error {} diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 319485cf11..a2550cea75 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -23,7 +23,7 @@ import type { Suite, TestCase } from '../common/test'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import { FixtureRunner } from './fixtureRunner'; import { ManualPromise, gracefullyCloseAll, removeFolders } from 'playwright-core/lib/utils'; -import { TestInfoImpl, type TestStage } from './testInfo'; +import { SkipError, TestInfoImpl } from './testInfo'; import { ProcessRunner } from '../common/process'; import { loadTestFile } from '../common/testLoader'; import { applyRepeatEachIndex, bindFileSuiteToProject, filterTestsRemoveEmptySuites } from '../common/suiteUtils'; @@ -31,6 +31,7 @@ import { PoolBuilder } from '../common/poolBuilder'; import type { TestInfoError } from '../../types/test'; import type { Location } from '../../types/testReporter'; import { inheritFixutreNames } from '../common/fixtures'; +import { TimeoutManagerError } from './timeoutManager'; export class WorkerMain extends ProcessRunner { private _params: WorkerInitParams; @@ -147,8 +148,11 @@ export class WorkerMain extends ProcessRunner { // TODO: separate timeout for teardown? const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); await fakeTestInfo._runAsStage({ title: 'teardown scopes', runnableType: 'teardown' }, async () => { - await this._fixtureRunner.teardownScope('test', fakeTestInfo); - await this._fixtureRunner.teardownScope('worker', fakeTestInfo); + try { + await this._fixtureRunner.teardownScope('test', fakeTestInfo); + } finally { + await this._fixtureRunner.teardownScope('worker', fakeTestInfo); + } }); this._fatalErrors.push(...fakeTestInfo.errors); } @@ -166,7 +170,7 @@ export class WorkerMain extends ProcessRunner { // and unhandled errors - both lead to the test failing. This is good for regular tests, // so that you can, e.g. expect() from inside an event handler. The test fails, // and we restart the worker. - this._currentTest._unhandledError(error); + this._currentTest._failWithError(error, true /* isHardError */, true /* retriable */); // For tests marked with test.fail(), this might be a problem when unhandled error // is not coming from the user test code (legit failure), but from fixtures or test runner. @@ -306,7 +310,7 @@ export class WorkerMain extends ProcessRunner { this._lastRunningTests.shift(); let shouldRunAfterEachHooks = false; - await testInfo._runAsStage({ title: 'setup and test', runnableType: 'test', allowSkip: true, stopOnChildError: true }, async () => { + await testInfo._runAsStage({ title: 'setup and test', runnableType: 'test', allowSkip: true }, async () => { await testInfo._runAsStage({ title: 'start tracing', canTimeout: true }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. @@ -332,14 +336,13 @@ export class WorkerMain extends ProcessRunner { await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - const beforeHooksStage: TestStage = { title: 'Before Hooks', stepCategory: 'hook', stopOnChildError: true }; - await testInfo._runAsStage(beforeHooksStage, async () => { + await testInfo._runAsStage({ title: 'Before Hooks', stepCategory: 'hook' }, async () => { // Run "beforeAll" hooks, unless already run during previous tests. for (const suite of suites) await this._runBeforeAllHooksForSuite(suite, testInfo); // Run "beforeEach" hooks. Once started with "beforeEach", we must run all "afterEach" hooks as well. - shouldRunAfterEachHooks = !beforeHooksStage.error && !beforeHooksStage.triggeredSkip && !beforeHooksStage.triggeredTimeout; + shouldRunAfterEachHooks = true; await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); // Setup fixtures required by the test. @@ -356,7 +359,7 @@ export class WorkerMain extends ProcessRunner { const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); }); - }); + }).catch(() => {}); // Ignore top-level error, we still have to run after hooks. // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; @@ -368,34 +371,56 @@ export class WorkerMain extends ProcessRunner { stepCategory: 'hook', runnableType: 'afterHooks', runnableSlot: afterHooksSlot, - continueOnChildTimeout: true, // Make sure the full cleanup still runs after regular cleanup timeout. }, async () => { - // Wrap cleanup steps in a stage, to stop running after one of them times out. - await testInfo._runAsStage({ title: 'regular cleanup' }, async () => { + let firstAfterHooksError: Error | undefined; + let didTimeoutInRegularCleanup = false; + + try { // Run "immediately upon test function finish" callback. await testInfo._runAsStage({ title: 'on-test-function-finish', canTimeout: true }, async () => testInfo._onDidFinishTestFunction?.()); + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInRegularCleanup = true; + firstAfterHooksError = firstAfterHooksError ?? error; + } + try { // Run "afterEach" hooks, unless we failed at beforeAll stage. - if (shouldRunAfterEachHooks) + if (!didTimeoutInRegularCleanup && shouldRunAfterEachHooks) await this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo); + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInRegularCleanup = true; + firstAfterHooksError = firstAfterHooksError ?? error; + } - // Teardown test-scoped fixtures. Attribute to 'test' so that users understand - // they should probably increase the test timeout to fix this issue. - await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { - await this._fixtureRunner.teardownScope('test', testInfo); - }); + try { + if (!didTimeoutInRegularCleanup) { + // Teardown test-scoped fixtures. Attribute to 'test' so that users understand + // they should probably increase the test timeout to fix this issue. + await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { + await this._fixtureRunner.teardownScope('test', testInfo); + }); + } + } catch (error) { + if (error instanceof TimeoutManagerError) + didTimeoutInRegularCleanup = true; + firstAfterHooksError = firstAfterHooksError ?? error; + } - // Run "afterAll" hooks for suites that are not shared with the next test. - // In case of failure the worker will be stopped and we have to make sure that afterAll - // hooks run before worker fixtures teardown. - // Continue running "afterAll" hooks even after some of them timeout. - await testInfo._runAsStage({ title: `after hooks suites`, continueOnChildTimeout: true }, async () => { - for (const suite of reversedSuites) { - if (!nextSuites.has(suite) || testInfo._isFailure()) - await this._runAfterAllHooksForSuite(suite, testInfo); + // Run "afterAll" hooks for suites that are not shared with the next test. + // In case of failure the worker will be stopped and we have to make sure that afterAll + // hooks run before worker fixtures teardown. + for (const suite of reversedSuites) { + if (!nextSuites.has(suite) || testInfo._isFailure()) { + try { + await this._runAfterAllHooksForSuite(suite, testInfo); + } catch (error) { + // Continue running "afterAll" hooks even after some of them timeout. + firstAfterHooksError = firstAfterHooksError ?? error; } - }); - }); + } + } if (testInfo._isFailure()) this._isStopped = true; @@ -407,24 +432,40 @@ export class WorkerMain extends ProcessRunner { // Give it more time for the full cleanup. const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStage({ title: 'full cleanup', runnableType: 'teardown', runnableSlot: teardownSlot }, async () => { + try { // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. - await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { + await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test', runnableSlot: teardownSlot }, async () => { await this._fixtureRunner.teardownScope('test', testInfo); }); + } catch (error) { + firstAfterHooksError = firstAfterHooksError ?? error; + } - for (const suite of reversedSuites) + for (const suite of reversedSuites) { + try { await this._runAfterAllHooksForSuite(suite, testInfo); + } catch (error) { + firstAfterHooksError = firstAfterHooksError ?? error; + } + } + try { // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. - await this._fixtureRunner.teardownScope('worker', testInfo); - }); + await testInfo._runAsStage({ title: 'teardown worker scope', runnableType: 'teardown', runnableSlot: teardownSlot }, async () => { + await this._fixtureRunner.teardownScope('worker', testInfo); + }); + } catch (error) { + firstAfterHooksError = firstAfterHooksError ?? error; + } } - }); + + if (firstAfterHooksError) + throw firstAfterHooksError; + }).catch(() => {}); // Ignore top-level error. await testInfo._runAsStage({ title: 'stop tracing' }, async () => { await testInfo._tracing.stopIfNeeded(); - }); + }).catch(() => {}); // Ignore top-level error. testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; @@ -472,38 +513,47 @@ export class WorkerMain extends ProcessRunner { private async _runAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl, type: 'beforeAll' | 'afterAll', extraAnnotations?: Annotation[]) { // Always run all the hooks, and capture the first error. - await testInfo._runAsStage({ title: `${type} hooks`, continueOnChildTimeout: true }, async () => { - for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { + let firstError: Error | undefined; + for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { + try { // Separate time slot for each beforeAll/afterAll hook. const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - const stage: TestStage = { + await testInfo._runAsStage({ title: hook.title, runnableType: hook.type, runnableSlot: timeSlot, stepCategory: 'hook', location: hook.location, - continueOnChildTimeout: true, // Make sure to teardown the scope even after hook timeout. - }; - await testInfo._runAsStage(stage, async () => { + }, async () => { const existingAnnotations = new Set(testInfo.annotations); - await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); - if (extraAnnotations) { - // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. - const newAnnotations = testInfo.annotations.filter(a => !existingAnnotations.has(a)); - extraAnnotations.push(...newAnnotations); + try { + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); + } finally { + if (extraAnnotations) { + // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. + const newAnnotations = testInfo.annotations.filter(a => !existingAnnotations.has(a)); + extraAnnotations.push(...newAnnotations); + } + // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. + // Note: we must teardown even after hook fails, because we'll run more hooks. + await this._fixtureRunner.teardownScope('test', testInfo); } - // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. - // Note: we must teardown even after hook fails, because we'll run more hooks. - await this._fixtureRunner.teardownScope('test', testInfo); }); - if ((stage.error || stage.triggeredTimeout) && type === 'beforeAll' && !this._skipRemainingTestsInSuite) { + } catch (error) { + firstError = firstError ?? error; + // Skip in beforeAll/modifier prevents others from running. + if (type === 'beforeAll' && (error instanceof SkipError)) + break; + if (type === 'beforeAll' && !this._skipRemainingTestsInSuite) { // This will inform dispatcher that we should not run more tests from this group // because we had a beforeAll error. // This behavior avoids getting the same common error for each test. this._skipRemainingTestsInSuite = suite; } } - }); + } + if (firstError) + throw firstError; } private async _runAfterAllHooksForSuite(suite: Suite, testInfo: TestInfoImpl) { @@ -514,10 +564,11 @@ export class WorkerMain extends ProcessRunner { } private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl) { - // Wrap hooks in a stage, to always run all of them and capture the first error. - await testInfo._runAsStage({ title: `${type} hooks` }, async () => { - const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); - for (const hook of hooks) { + // Always run all the hooks, unless one of the times out, and capture the first error. + let firstError: Error | undefined; + const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); + for (const hook of hooks) { + try { await testInfo._runAsStage({ title: hook.title, runnableType: hook.type, @@ -526,8 +577,14 @@ export class WorkerMain extends ProcessRunner { }, async () => { await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test'); }); + } catch (error) { + if (error instanceof TimeoutManagerError) + throw error; + firstError = firstError ?? error; } - }); + } + if (firstError) + throw firstError; } } diff --git a/tests/playwright-test/fixture-errors.spec.ts b/tests/playwright-test/fixture-errors.spec.ts index 90dc344c4d..59f875e991 100644 --- a/tests/playwright-test/fixture-errors.spec.ts +++ b/tests/playwright-test/fixture-errors.spec.ts @@ -495,8 +495,7 @@ test('should not report fixture teardown timeout twice', async ({ runInlineTest expect(result.failed).toBe(1); expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture" ran out of time.'); expect(result.output).not.toContain('base.extend'); // Should not point to the location. - // TODO: this should be "not.toContain" actually. - expect(result.output).toContain('Worker teardown timeout of 1000ms exceeded while tearing down "fixture".'); + expect(result.output).not.toContain('Worker teardown timeout'); }); test('should handle fixture teardown error after test timeout and continue', async ({ runInlineTest }) => { @@ -676,3 +675,29 @@ test('tear down base fixture after error in derived', async ({ runInlineTest }) 'context teardown failed', ]); }); + +test('should not continue with scope teardown after fixture teardown timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + fixture: async ({ }, use) => { + await use(); + console.log('in fixture teardown'); + }, + fixture2: async ({ fixture }, use) => { + await use(); + console.log('in fixture2 teardown'); + await new Promise(() => {}); + }, + }); + test.use({ trace: 'on' }); + test('good', async ({ fixture2 }) => { + }); + `, + }, { reporter: 'list', timeout: 1000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture2" ran out of time.'); + expect(result.output).not.toContain('in fixture teardown'); +}); From b3edb2a130295ad2d2a3b5f2e437273eab2d6443 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 6 Mar 2024 10:47:04 +0100 Subject: [PATCH 064/141] docs: add release-notes for language bindings (#29825) --- docs/src/release-notes-csharp.md | 39 ++++++++++++++++++++++++++++++++ docs/src/release-notes-java.md | 38 +++++++++++++++++++++++++++++++ docs/src/release-notes-python.md | 37 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 9dc037c1a8..edc12f0a75 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,45 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```csharp +// Setup the handler. +await Page.AddLocatorHandlerAsync( + Page.GetByRole(AriaRole.Heading, new() { Name = "Hej! You are in control of your cookies." }), + async () => + { + await Page.GetByRole(AriaRole.Button, new() { Name = "Accept all" }).ClickAsync(); + }); +// Write the test as usual. +await Page.GotoAsync("https://www.ikea.com/"); +await Page.GetByRole(AriaRole.Link, new() { Name = "Collection of blue and white" }).ClickAsync(); +await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Light and easy" })).ToBeVisibleAsync(); +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 121.0.6167.57 +* Mozilla Firefox 121.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 120 +* Microsoft Edge 120 + ## Version 1.41 ### New APIs diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index a792365d21..5029f2bc88 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,44 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```java +// Setup the handler. +page.addLocatorHandler( + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Hej! You are in control of your cookies.")), + () - > { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Accept all")).click(); + }); +// Write the test as usual. +page.navigate("https://www.ikea.com/"); +page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Collection of blue and white")).click(); +assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Light and easy"))).isVisible(); +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 121.0.6167.57 +* Mozilla Firefox 121.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 120 +* Microsoft Edge 120 + ## Version 1.41 ### New APIs diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 662df326e2..2b01538f37 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,43 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.42 + +### New Locator Handler + +New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. + +```python +# Setup the handler. +page.add_locator_handler( + page.get_by_role("heading", name="Hej! You are in control of your cookies."), + lambda: page.get_by_role("button", name="Accept all").click(), +) +# Write the test as usual. +page.goto("https://www.ikea.com/") +page.get_by_role("link", name="Collection of blue and white").click() +expect(page.get_by_role("heading", name="Light and easy")).to_be_visible() +``` + +### New APIs + +- [`method: Page.pdf`] accepts two new options [`option: tagged`] and [`option: outline`]. + +### Announcements + +* ⚠️ Ubuntu 18 is not supported anymore. + +### Browser Versions + +* Chromium 121.0.6167.57 +* Mozilla Firefox 121.0 +* WebKit 17.4 + +This version was also tested against the following stable channels: + +* Google Chrome 120 +* Microsoft Edge 120 + ## Version 1.41 ### New APIs From a678561cda91cb1e7c15d0e23b404e9ad2537f99 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 6 Mar 2024 11:02:51 +0100 Subject: [PATCH 065/141] docs(release-notes): fix wrong browser versions --- docs/src/release-notes-csharp.md | 8 ++++---- docs/src/release-notes-java.md | 8 ++++---- docs/src/release-notes-python.md | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index edc12f0a75..040b66e218 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -34,14 +34,14 @@ await Expect(Page.GetByRole(AriaRole.Heading, new() { Name = "Light and easy" }) ### Browser Versions -* Chromium 121.0.6167.57 -* Mozilla Firefox 121.0 +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 * WebKit 17.4 This version was also tested against the following stable channels: -* Google Chrome 120 -* Microsoft Edge 120 +* Google Chrome 122 +* Microsoft Edge 123 ## Version 1.41 diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 5029f2bc88..b6c06e995a 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -33,14 +33,14 @@ assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName( ### Browser Versions -* Chromium 121.0.6167.57 -* Mozilla Firefox 121.0 +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 * WebKit 17.4 This version was also tested against the following stable channels: -* Google Chrome 120 -* Microsoft Edge 120 +* Google Chrome 122 +* Microsoft Edge 123 ## Version 1.41 diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 2b01538f37..72481e0284 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -32,14 +32,14 @@ expect(page.get_by_role("heading", name="Light and easy")).to_be_visible() ### Browser Versions -* Chromium 121.0.6167.57 -* Mozilla Firefox 121.0 +* Chromium 123.0.6312.4 +* Mozilla Firefox 123.0 * WebKit 17.4 This version was also tested against the following stable channels: -* Google Chrome 120 -* Microsoft Edge 120 +* Google Chrome 122 +* Microsoft Edge 123 ## Version 1.41 From 425f737eb6acae72ee75ab278b34d969891260cf Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Wed, 6 Mar 2024 17:33:06 +0100 Subject: [PATCH 066/141] feat: exposes tags in testInfo (#29794) Fixes #29793. --------- Signed-off-by: Lukas Bockstaller Co-authored-by: Dmitry Gozman --- docs/src/test-api/class-testinfo.md | 8 +++++ packages/playwright/src/worker/testInfo.ts | 2 ++ packages/playwright/types/test.d.ts | 7 +++++ .../playwright-test-fixtures.ts | 6 ++-- tests/playwright-test/test-tag.spec.ts | 29 +++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/src/test-api/class-testinfo.md b/docs/src/test-api/class-testinfo.md index d766920b06..97e9c16ebf 100644 --- a/docs/src/test-api/class-testinfo.md +++ b/docs/src/test-api/class-testinfo.md @@ -210,6 +210,14 @@ Optional description that will be reflected in a test report. Test function as passed to `test(title, testFunction)`. +## property: TestInfo.tags +* since: v1.43 +- type: <[Array]<[string]>> + +Tags that apply to the test. Learn more about [tags](../test-annotations.md#tag-tests). + +Note that any changes made to this list while the test is running will not be visible to test reporters. + ## property: TestInfo.testId * since: v1.32 - type: <[string]> diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 70880af1c2..4678812759 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -92,6 +92,7 @@ export class TestInfoImpl implements TestInfo { readonly titlePath: string[]; readonly file: string; readonly line: number; + readonly tags: string[]; readonly column: number; readonly fn: Function; expectedStatus: TestStatus; @@ -167,6 +168,7 @@ export class TestInfoImpl implements TestInfo { this.file = test?.location.file ?? ''; this.line = test?.location.line ?? 0; this.column = test?.location.column ?? 0; + this.tags = test?.tags ?? []; this.fn = test?.fn ?? (() => {}); this.expectedStatus = test?.expectedStatus ?? 'skipped'; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 1e610baf53..497b137774 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -2312,6 +2312,13 @@ export interface TestInfo { */ status?: "passed"|"failed"|"timedOut"|"skipped"|"interrupted"; + /** + * Tags that apply to the test. Learn more about [tags](https://playwright.dev/docs/test-annotations#tag-tests). + * + * Note that any changes made to this list while the test is running will not be visible to test reporters. + */ + tags: Array; + /** * Test id matching the test case id in the reporter API. */ diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 825068f51f..7850a52f07 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -254,8 +254,8 @@ type Fixtures = { }; export const test = base - .extend(commonFixtures) - .extend(serverFixtures) + .extend(commonFixtures as any) + .extend(serverFixtures as any) .extend({ writeFiles: async ({}, use, testInfo) => { await use(files => writeFiles(testInfo, files, false)); @@ -455,4 +455,4 @@ export default defineConfig({ }, projects: [{name: 'default'}], }); -`; \ No newline at end of file +`; diff --git a/tests/playwright-test/test-tag.spec.ts b/tests/playwright-test/test-tag.spec.ts index e442c2acaa..9259e3b1c6 100644 --- a/tests/playwright-test/test-tag.spec.ts +++ b/tests/playwright-test/test-tag.spec.ts @@ -146,3 +146,32 @@ test('should enforce @ symbol', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toContain(`Error: Tag must start with "@" symbol, got "foo" instead.`); }); + +test('should be included in testInfo', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test without tag', async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual([]); + }); + test('test with tag',{ tag: '@tag1' }, async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual(["@tag1"]); + }); + `, + }); + expect(result.exitCode).toBe(0); +}); + +test('should be included in testInfo if comming from describe', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test.describe('describe with tag', { tag: '@tag2' }, async ()=>{ + test('test with tag', async ({}, testInfo) => { + expect(testInfo.tags).toStrictEqual(["@tag2"]); + }); + }); + `, + }); + expect(result.exitCode).toBe(0); +}); From d125ff4d39f521a335959af80f19918a3fa98c18 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 6 Mar 2024 17:51:44 +0100 Subject: [PATCH 067/141] docs: fix inconsistent .NET snippets (#29831) --- docs/src/api/class-browsercontext.md | 9 ++++++--- docs/src/api/class-page.md | 2 +- docs/src/dialogs.md | 13 ++++++++----- docs/src/network.md | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index c47a1892cb..11df502854 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -186,7 +186,10 @@ context.on("dialog", lambda dialog: dialog.accept()) ``` ```csharp -context.Dialog += (_, dialog) => dialog.AcceptAsync(); +Context.Dialog += async (_, dialog) => +{ + await dialog.AcceptAsync(); +}; ``` :::note @@ -390,7 +393,7 @@ browser_context.add_init_script(path="preload.js") ``` ```csharp -await context.AddInitScriptAsync(scriptPath: "preload.js"); +await Context.AddInitScriptAsync(scriptPath: "preload.js"); ``` :::note @@ -1180,7 +1183,7 @@ context.route("/api/**", handle_route) await page.RouteAsync("/api/**", async r => { if (r.Request.PostData.Contains("my-string")) - await r.FulfillAsync(body: "mocked-data"); + await r.FulfillAsync(new() { Body = "mocked-data" }); else await r.ContinueAsync(); }); diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index c0cd3d0bea..4fe5421ab2 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -608,7 +608,7 @@ page.add_init_script(path="./preload.js") ``` ```csharp -await page.AddInitScriptAsync(scriptPath: "./preload.js"); +await Page.AddInitScriptAsync(scriptPath: "./preload.js"); ``` :::note diff --git a/docs/src/dialogs.md b/docs/src/dialogs.md index c210ffdb3c..1d8938778c 100644 --- a/docs/src/dialogs.md +++ b/docs/src/dialogs.md @@ -32,8 +32,11 @@ page.get_by_role("button").click() ``` ```csharp -page.Dialog += (_, dialog) => dialog.AcceptAsync(); -await page.GetByRole(AriaRole.Button).ClickAsync(); +Page.Dialog += async (_, dialog) => +{ + await dialog.AcceptAsync(); +}; +await Page.GetByRole(AriaRole.Button).ClickAsync(); ``` :::note @@ -116,10 +119,10 @@ page.close(run_before_unload=True) ``` ```csharp -page.Dialog += (_, dialog) => +Page.Dialog += async (_, dialog) => { Assert.AreEqual("beforeunload", dialog.Type); - dialog.DismissAsync(); + await dialog.DismissAsync(); }; -await page.CloseAsync(runBeforeUnload: true); +await Page.CloseAsync(new() { RunBeforeUnload = true }); ``` diff --git a/docs/src/network.md b/docs/src/network.md index 438e929e0c..f1fbc620b3 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -550,7 +550,7 @@ await page.RouteAsync("**/*", async route => { }); // Continue requests as POST. -await page.RouteAsync("**/*", async route => await route.ContinueAsync(method: "POST")); +await Page.RouteAsync("**/*", async route => await route.ContinueAsync(new() { Method = "POST" })); ``` You can continue requests with modifications. Example above removes an HTTP header from the outgoing requests. From 0c3f60e95e2a174d511c86b07f9c1647b1700f6c Mon Sep 17 00:00:00 2001 From: Sarkis Matinyan Date: Wed, 6 Mar 2024 12:29:35 -0800 Subject: [PATCH 068/141] fix(ui): show stack frames in ui mode (#29560) #29558, #29500 --- packages/trace-viewer/src/ui/sourceTab.tsx | 8 +++-- packages/trace-viewer/src/ui/uiModeView.tsx | 1 - packages/trace-viewer/src/ui/workbench.tsx | 5 ++- tests/playwright-test/reporter-html.spec.ts | 35 ++++++++++++++++++++- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index aebbbd872f..a3acad64a2 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -26,11 +26,11 @@ import type { StackFrame } from '@protocol/channels'; export const SourceTab: React.FunctionComponent<{ stack: StackFrame[] | undefined, + stackFrameLocation: 'bottom' | 'right', sources: Map, - hideStackFrames?: boolean, rootDir?: string, fallbackLocation?: SourceLocation, -}> = ({ stack, sources, hideStackFrames, rootDir, fallbackLocation }) => { +}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation }) => { const [lastStack, setLastStack] = React.useState(); const [selectedFrame, setSelectedFrame] = React.useState(0); @@ -78,7 +78,9 @@ export const SourceTab: React.FunctionComponent<{ return { source, highlight, targetLine, fileName }; }, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] }); - return + const showStackFrames = (stack?.length ?? 0) > 1; + + return
{fileName &&
{fileName}
} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 164167f272..c7406fb49f 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -589,7 +589,6 @@ const TraceView: React.FC<{ return void, isLive?: boolean, status?: UITestStatus, -}> = ({ model, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status }) => { +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status }) => { const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); @@ -158,8 +157,8 @@ export const Workbench: React.FunctionComponent<{ render: () => }; const consoleTab: TabbedPaneTabModel = { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 84f37be292..d6a07a47a3 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -442,8 +442,11 @@ for (const useIntermediateMergeReport of [false, true] as const) { `, 'a.test.js': ` import { test, expect } from '@playwright/test'; + async function evaluateWrapper(page, expression) { + await page.evaluate(expression); + } test('passes', async ({ page }) => { - await page.evaluate('2 + 2'); + await evaluateWrapper(page, '2 + 2'); }); `, }, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }); @@ -468,6 +471,36 @@ for (const useIntermediateMergeReport of [false, true] as const) { await expect(page.getByTestId('stack-trace-list').locator('.list-view-entry.selected')).toContainText('a.test.js'); }); + test('should not show stack trace', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = { use: { trace: 'on' } }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passes', async ({ page }) => { + await page.evaluate('2 + 2'); + }); + `, + }, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + await showReport(); + await page.click('text=passes'); + await page.click('img'); + await page.click('.action-title >> text=page.evaluate'); + await page.click('text=Source'); + + await expect(page.locator('.CodeMirror-line')).toContainText([ + /import.*test/, + /page\.evaluate/ + ]); + await expect(page.locator('.source-line-running')).toContainText('page.evaluate'); + + await expect(page.getByTestId('stack-trace-list')).toHaveCount(0); + }); + test('should show trace title', async ({ runInlineTest, page, showReport }) => { const result = await runInlineTest({ 'playwright.config.js': ` From 006ee7f3b0a3b7beb3b882d4ff3262229c6c7707 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 6 Mar 2024 12:31:54 -0800 Subject: [PATCH 069/141] feat: separate timeout for after hooks (#29828) Instead of sharing the timeout with the test, and then extending it when test times out, we give after hooks a separate timeout. --- .../playwright/src/worker/fixtureRunner.ts | 48 ++++++++----------- packages/playwright/src/worker/workerMain.ts | 12 +++-- tests/playwright-test/fixture-errors.spec.ts | 32 +++++++++++++ tests/playwright-test/reporter-html.spec.ts | 6 +-- 4 files changed, 65 insertions(+), 33 deletions(-) diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 8036bee289..107bdb9c1f 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -30,7 +30,6 @@ class Fixture { private _useFuncFinished: ManualPromise | undefined; private _selfTeardownComplete: Promise | undefined; - private _teardownWithDepsComplete: Promise | undefined; private _setupDescription: FixtureDescription; private _teardownDescription: FixtureDescription; private _shouldGenerateStep = false; @@ -135,9 +134,7 @@ class Fixture { stepCategory: this._shouldGenerateStep ? 'fixture' : undefined, }, async () => { testInfo._timeoutManager.setCurrentFixture(this._teardownDescription); - if (!this._teardownWithDepsComplete) - this._teardownWithDepsComplete = this._teardownInternal(); - await this._teardownWithDepsComplete; + await this._teardownInternal(); testInfo._timeoutManager.setCurrentFixture(undefined); }); } @@ -153,6 +150,7 @@ class Fixture { } if (this._useFuncFinished) { this._useFuncFinished.resolve(); + this._useFuncFinished = undefined; await this._selfTeardownComplete; } } finally { @@ -205,36 +203,32 @@ export class FixtureRunner { } async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { - if (scope === 'worker') { - const collector = new Set(); - for (const fixture of this.instanceForId.values()) - fixture._collectFixturesInTeardownOrder('test', collector); - // Clean up test-scoped fixtures that did not teardown because of timeout in one of them. - // This preserves fixture integrity for worker fixtures. - for (const fixture of collector) - fixture._cleanupInstance(); - this.testScopeClean = true; - } - // Teardown fixtures in the reverse order. const fixtures = Array.from(this.instanceForId.values()).reverse(); const collector = new Set(); for (const fixture of fixtures) fixture._collectFixturesInTeardownOrder(scope, collector); - let firstError: Error | undefined; - for (const fixture of collector) { - try { - await fixture.teardown(testInfo); - } catch (error) { - if (error instanceof TimeoutManagerError) - throw error; - firstError = firstError ?? error; + try { + let firstError: Error | undefined; + for (const fixture of collector) { + try { + await fixture.teardown(testInfo); + } catch (error) { + if (error instanceof TimeoutManagerError) + throw error; + firstError = firstError ?? error; + } } + if (firstError) + throw firstError; + } finally { + // To preserve fixtures integrity, forcefully cleanup fixtures that did not teardown + // due to a timeout in one of them. + for (const fixture of collector) + fixture._cleanupInstance(); + if (scope === 'test') + this.testScopeClean = true; } - if (scope === 'test') - this.testScopeClean = true; - if (firstError) - throw firstError; } async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only'): Promise { diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index a2550cea75..5cd95d77e2 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -364,8 +364,9 @@ export class WorkerMain extends ProcessRunner { // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; - // A timed-out test gets a full additional timeout to run after hooks. - const afterHooksSlot = testInfo._didTimeout ? { timeout: this._project.project.timeout, elapsed: 0 } : undefined; + // After hooks get an additional timeout. + const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout); + const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 }; await testInfo._runAsStage({ title: 'After Hooks', stepCategory: 'hook', @@ -467,7 +468,7 @@ export class WorkerMain extends ProcessRunner { await testInfo._tracing.stopIfNeeded(); }).catch(() => {}); // Ignore top-level error. - testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; + testInfo.duration = (testInfo._timeoutManager.defaultSlotTimings().elapsed + afterHooksSlot.elapsed) | 0; this._currentTest = null; setCurrentTestInfo(null); @@ -624,4 +625,9 @@ function formatTestTitle(test: TestCase, projectName: string) { return `${projectTitle}${location} › ${titles.join(' › ')}`; } +function calculateMaxTimeout(t1: number, t2: number) { + // Zero means "no timeout". + return (!t1 || !t2) ? 0 : Math.max(t1, t2); +} + export const create = (params: WorkerInitParams) => new WorkerMain(params); diff --git a/tests/playwright-test/fixture-errors.spec.ts b/tests/playwright-test/fixture-errors.spec.ts index 59f875e991..a58814104f 100644 --- a/tests/playwright-test/fixture-errors.spec.ts +++ b/tests/playwright-test/fixture-errors.spec.ts @@ -425,6 +425,38 @@ test('should give enough time for fixture teardown', async ({ runInlineTest }) = }); `, }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.outputLines).toEqual([ + 'teardown start', + 'teardown finished', + ]); +}); + +test('should not give enough time for second fixture teardown after timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + fixture2: async ({ }, use) => { + await use(); + console.log('\\n%%teardown2 start'); + await new Promise(f => setTimeout(f, 3000)); + console.log('\\n%%teardown2 finished'); + }, + fixture: async ({ fixture2 }, use) => { + await use(); + console.log('\\n%%teardown start'); + await new Promise(f => setTimeout(f, 3000)); + console.log('\\n%%teardown finished'); + }, + }); + test('fast enough but close', async ({ fixture }) => { + test.setTimeout(3000); + await new Promise(f => setTimeout(f, 2000)); + }); + `, + }, { timeout: 2000 }); expect(result.exitCode).toBe(1); expect(result.failed).toBe(1); expect(result.output).toContain('Test finished within timeout of 3000ms, but tearing down "fixture" ran out of time.'); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index d6a07a47a3..a819f25be8 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2052,9 +2052,9 @@ for (const useIntermediateMergeReport of [false, true] as const) { // Failing test first, then sorted by the run order. await expect(page.locator('.test-file-test')).toHaveText([ - /main › fails\d+m?smain.spec.ts:9/, - /main › first › passes\d+m?sfirst.ts:12/, - /main › second › passes\d+m?ssecond.ts:5/, + /main › fails\d+m?s?main.spec.ts:9/, + /main › first › passes\d+m?s?first.ts:12/, + /main › second › passes\d+m?s?second.ts:5/, ]); }); From 591d327eac546b38916928bb1d2046d401b20583 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 7 Mar 2024 16:02:31 +0100 Subject: [PATCH 070/141] chore: trace-viewer a11y fixes (#29838) --- packages/trace-viewer/src/ui/workbenchLoader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 2df5d7fca4..9d17e3fc9d 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -160,8 +160,8 @@ export const WorkbenchLoader: React.FunctionComponent<{ } {!isServer && !dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) &&
-
{processingErrorMessage}
-
Drop Playwright Trace to load
+
{processingErrorMessage}
+
Drop Playwright Trace to load
or
+ }} type='button'>Select file(s)
Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere, it opens it locally.
} From 84d33089693e46e449323a68ced0dc621d1f5faa Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 7 Mar 2024 22:42:21 +0100 Subject: [PATCH 073/141] chore: limit trace-viewer minimum viewport (#29850) https://github.com/microsoft/playwright/issues/29100 --- packages/trace-viewer/src/ui/timeline.css | 1 + packages/trace-viewer/src/ui/workbenchLoader.css | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/trace-viewer/src/ui/timeline.css b/packages/trace-viewer/src/ui/timeline.css index dfc9e23682..be06c4c53b 100644 --- a/packages/trace-viewer/src/ui/timeline.css +++ b/packages/trace-viewer/src/ui/timeline.css @@ -23,6 +23,7 @@ cursor: text; user-select: none; margin-left: 10px; + overflow-x: clip; } .timeline-divider { diff --git a/packages/trace-viewer/src/ui/workbenchLoader.css b/packages/trace-viewer/src/ui/workbenchLoader.css index b7f8130678..5adb0401ae 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.css +++ b/packages/trace-viewer/src/ui/workbenchLoader.css @@ -122,3 +122,10 @@ body.dark-mode .drop-target { text-overflow: ellipsis; text-wrap: nowrap; } + +/* Limit to a reasonable minimum viewport */ +html, body { + min-width: 550px; + min-height: 450px; + overflow: auto; +} From 875ce1cf0968e84a2d4affd6e15681cfd6ecf804 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 7 Mar 2024 14:07:04 -0800 Subject: [PATCH 074/141] fix(chromium): use blockedReason as failure reason when available (#29849) This covers blocked requests, e.g. mixed-content, that receive `loadingFailed` with empty `errorText`. Also, forcefully resolve `allHeaders()` in this case, since we know there will be no actual network headers. Fixes #29833. --- .../src/server/chromium/crNetworkManager.ts | 5 ++- tests/page/page-network-request.spec.ts | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 9e9eac0477..afe753046a 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -523,9 +523,12 @@ export class CRNetworkManager { response.setTransferSize(null); response.setEncodedBodySize(null); response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp)); + } else { + // Loading failed before response has arrived - there will be no extra info events. + request.request.setRawRequestHeaders(null); } this._deleteRequest(request); - request.request._setFailureText(event.errorText); + request.request._setFailureText(event.errorText || event.blockedReason || ''); (this._page?._frameManager || this._serviceWorker)!.requestFailed(request.request, !!event.canceled); } diff --git a/tests/page/page-network-request.spec.ts b/tests/page/page-network-request.spec.ts index 70fc77b61f..37eb1a20dd 100644 --- a/tests/page/page-network-request.spec.ts +++ b/tests/page/page-network-request.spec.ts @@ -17,6 +17,7 @@ import { test as it, expect } from './pageTest'; import { attachFrame } from '../config/utils'; +import fs from 'fs'; it('should work for main frame navigation request', async ({ page, server }) => { const requests = []; @@ -482,3 +483,41 @@ it('page.reload return 304 status code', async ({ page, server, browserName }) = expect(response2.statusText()).toBe('Not Modified'); } }); + +it('should handle mixed-content blocked requests', async ({ page, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29833' }); + it.skip(browserName !== 'chromium', 'FF and WK actually succeed with the request, and block afterwards'); + + await page.route('**/mixedcontent.html', route => { + void route.fulfill({ + status: 200, + contentType: 'text/html', + body: ` + + + + +- + `, + }); + }); + await page.route('**/iconfont.woff2', async route => { + const body = await fs.promises.readFile(asset('webfont/iconfont2.woff')); + await route.fulfill({ body }); + }); + + const [request] = await Promise.all([ + page.waitForEvent('requestfailed', r => r.url().includes('iconfont.woff2')), + page.goto('https://example.com/mixedcontent.html'), + ]); + const headers = await request.allHeaders(); + expect(headers['origin']).toBeTruthy(); + expect(request.failure().errorText).toBe('mixed-content'); +}); From 8f2c372bd81cd7dc8ede73cd1b81179a30ec0728 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Fri, 8 Mar 2024 01:15:19 -0800 Subject: [PATCH 075/141] feat(webkit): roll to r1987 (#29856) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index dae62ba9af..0c3d876745 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -33,7 +33,7 @@ }, { "name": "webkit", - "revision": "1986", + "revision": "1987", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From d214778548f10f86ba946d721ac00a89afab1c68 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 8 Mar 2024 15:19:36 -0800 Subject: [PATCH 076/141] chore(test runner): move timeout handling to the top, stop inheriting runnable (#29857) --- .../playwright/src/worker/fixtureRunner.ts | 49 +++---- packages/playwright/src/worker/testInfo.ts | 70 ++++------ .../playwright/src/worker/timeoutManager.ts | 46 +++---- packages/playwright/src/worker/workerMain.ts | 130 ++++++++---------- tests/playwright-test/hooks.spec.ts | 2 +- .../playwright-test/playwright.trace.spec.ts | 5 + tests/playwright-test/reporter.spec.ts | 6 +- tests/playwright-test/test-step.spec.ts | 45 ++++++ tests/playwright-test/timeout.spec.ts | 35 +++++ tests/playwright-test/ui-mode-trace.spec.ts | 1 + 10 files changed, 213 insertions(+), 176 deletions(-) diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 107bdb9c1f..f425f1cd3e 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -17,7 +17,7 @@ import { formatLocation, filterStackFile } from '../util'; import { ManualPromise } from 'playwright-core/lib/utils'; import type { TestInfoImpl } from './testInfo'; -import { TimeoutManagerError, type FixtureDescription } from './timeoutManager'; +import { TimeoutManagerError, type FixtureDescription, type RunnableDescription } from './timeoutManager'; import { fixtureParameterNames, type FixturePool, type FixtureRegistration, type FixtureScope } from '../common/fixtures'; import type { WorkerInfo } from '../../types/test'; import type { Location } from '../../types/testReporter'; @@ -32,8 +32,7 @@ class Fixture { private _selfTeardownComplete: Promise | undefined; private _setupDescription: FixtureDescription; private _teardownDescription: FixtureDescription; - private _shouldGenerateStep = false; - private _isInternalFixture = false; + private _stepInfo: { category: 'fixture', location?: Location } | undefined; _deps = new Set(); _usages = new Set(); @@ -41,22 +40,24 @@ class Fixture { this.runner = runner; this.registration = registration; this.value = null; + const shouldGenerateStep = !this.registration.hideStep && !this.registration.name.startsWith('_') && !this.registration.option; + const isInternalFixture = this.registration.location && filterStackFile(this.registration.location.file); const title = this.registration.customTitle || this.registration.name; + const location = isInternalFixture ? this.registration.location : undefined; + this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined; this._setupDescription = { title, phase: 'setup', - location: registration.location, + location, slot: this.registration.timeout === undefined ? undefined : { timeout: this.registration.timeout, elapsed: 0, } }; this._teardownDescription = { ...this._setupDescription, phase: 'teardown' }; - this._shouldGenerateStep = !this.registration.hideStep && !this.registration.name.startsWith('_') && !this.registration.option; - this._isInternalFixture = this.registration.location && filterStackFile(this.registration.location.file); } - async setup(testInfo: TestInfoImpl) { + async setup(testInfo: TestInfoImpl, runnable: RunnableDescription) { this.runner.instanceForId.set(this.registration.id, this); if (typeof this.registration.fn !== 'function') { @@ -66,13 +67,10 @@ class Fixture { await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - canTimeout: true, - location: this._isInternalFixture ? this.registration.location : undefined, - stepCategory: this._shouldGenerateStep ? 'fixture' : undefined, + runnable: { ...runnable, fixture: this._setupDescription }, + stepInfo: this._stepInfo, }, async () => { - testInfo._timeoutManager.setCurrentFixture(this._setupDescription); await this._setupInternal(testInfo); - testInfo._timeoutManager.setCurrentFixture(undefined); }); } @@ -126,16 +124,13 @@ class Fixture { await useFuncStarted; } - async teardown(testInfo: TestInfoImpl) { + async teardown(testInfo: TestInfoImpl, runnable: RunnableDescription) { await testInfo._runAsStage({ title: `fixture: ${this.registration.name}`, - canTimeout: true, - location: this._isInternalFixture ? this.registration.location : undefined, - stepCategory: this._shouldGenerateStep ? 'fixture' : undefined, + runnable: { ...runnable, fixture: this._teardownDescription }, + stepInfo: this._stepInfo, }, async () => { - testInfo._timeoutManager.setCurrentFixture(this._teardownDescription); await this._teardownInternal(); - testInfo._timeoutManager.setCurrentFixture(undefined); }); } @@ -202,7 +197,7 @@ export class FixtureRunner { collector.add(registration); } - async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl) { + async teardownScope(scope: FixtureScope, testInfo: TestInfoImpl, runnable: RunnableDescription) { // Teardown fixtures in the reverse order. const fixtures = Array.from(this.instanceForId.values()).reverse(); const collector = new Set(); @@ -212,7 +207,7 @@ export class FixtureRunner { let firstError: Error | undefined; for (const fixture of collector) { try { - await fixture.teardown(testInfo); + await fixture.teardown(testInfo, runnable); } catch (error) { if (error instanceof TimeoutManagerError) throw error; @@ -231,7 +226,7 @@ export class FixtureRunner { } } - async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only'): Promise { + async resolveParametersForFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only', runnable: RunnableDescription): Promise { const collector = new Set(); // Collect automatic fixtures. @@ -256,7 +251,7 @@ export class FixtureRunner { // Setup fixtures. for (const registration of collector) - await this._setupFixtureForRegistration(registration, testInfo); + await this._setupFixtureForRegistration(registration, testInfo, runnable); // Create params object. const params: { [key: string]: any } = {}; @@ -270,18 +265,18 @@ export class FixtureRunner { return params; } - async resolveParametersAndRunFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only') { - const params = await this.resolveParametersForFunction(fn, testInfo, autoFixtures); + async resolveParametersAndRunFunction(fn: Function, testInfo: TestInfoImpl, autoFixtures: 'worker' | 'test' | 'all-hooks-only', runnable: RunnableDescription) { + const params = await this.resolveParametersForFunction(fn, testInfo, autoFixtures, runnable); if (params === null) { // Do not run the function when fixture setup has already failed. return null; } - await testInfo._runAsStage({ title: 'run function', canTimeout: true }, async () => { + await testInfo._runAsStage({ title: 'run function', runnable }, async () => { await fn(params, testInfo); }); } - private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl): Promise { + private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl, runnable: RunnableDescription): Promise { if (registration.scope === 'test') this.testScopeClean = false; @@ -290,7 +285,7 @@ export class FixtureRunner { return fixture; fixture = new Fixture(this, registration); - await fixture.setup(testInfo); + await fixture.setup(testInfo, runnable); return fixture; } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 4678812759..372f01d93f 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -21,7 +21,7 @@ import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } fro import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; -import type { RunnableDescription, RunnableType, TimeSlot } from './timeoutManager'; +import type { RunnableDescription } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, serializeError, trimLongString } from '../util'; @@ -50,12 +50,8 @@ export interface TestStepInternal { export type TestStage = { title: string; - location?: Location; - stepCategory?: 'hook' | 'fixture'; - runnableType?: RunnableType; - runnableSlot?: TimeSlot; - canTimeout?: boolean; - allowSkip?: boolean; + stepInfo?: { category: 'hook' | 'fixture', location?: Location }; + runnable?: RunnableDescription; step?: TestStepInternal; }; @@ -69,7 +65,6 @@ export class TestInfoImpl implements TestInfo { private _hasHardError: boolean = false; readonly _tracing: TestTracing; - _didTimeout = false; _wasInterrupted = false; _lastStepId = 0; private readonly _requireFile: string; @@ -79,6 +74,7 @@ export class TestInfoImpl implements TestInfo { _onDidFinishTestFunction: (() => Promise) | undefined; private readonly _stages: TestStage[] = []; _hasNonRetriableError = false; + _allowSkips = false; // ------------ TestInfo fields ------------ readonly testId: string; @@ -354,13 +350,13 @@ export class TestInfoImpl implements TestInfo { // Do not overwrite any previous hard errors. // Some (but not all) scenarios include: // - expect() that fails after uncaught exception. - // - fail after the timeout, e.g. due to fixture teardown. + // - fail in fixture teardown after the test failure. if (isHardError && this._hasHardError) return; if (isHardError) this._hasHardError = true; if (this.status === 'passed' || this.status === 'skipped') - this.status = 'failed'; + this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; const serialized = serializeError(error); const step = (error as any)[stepSymbol] as TestStepInternal | undefined; if (step && step.boxedStack) @@ -370,43 +366,29 @@ export class TestInfoImpl implements TestInfo { } async _runAsStage(stage: TestStage, cb: () => Promise) { - // Inherit some properties from parent. - const parent = this._stages[this._stages.length - 1]; - stage.allowSkip = stage.allowSkip ?? parent?.allowSkip ?? false; - if (debugTest.enabled) { - const location = stage.location ? ` at "${formatLocation(stage.location)}"` : ``; + const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``; debugTest(`started stage "${stage.title}"${location}`); } - stage.step = stage.stepCategory ? this._addStep({ title: stage.title, category: stage.stepCategory, location: stage.location, wallTime: Date.now(), isStage: true }) : undefined; + stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, wallTime: Date.now(), isStage: true }) : undefined; this._stages.push(stage); - let runnable: RunnableDescription | undefined; - if (stage.canTimeout) { - // Choose the deepest runnable configuration. - runnable = { type: 'test' }; - for (const s of this._stages) { - if (s.runnableType) { - runnable.type = s.runnableType; - runnable.location = s.location; - } - if (s.runnableSlot) - runnable.slot = s.runnableSlot; - } - } - try { - await this._timeoutManager.withRunnable(runnable, async () => { - // Note: separate try/catch is here to report errors after timeout. - // This way we get a nice "locator.click" error after the test times out and closes the page. + await this._timeoutManager.withRunnable(stage.runnable, async () => { try { await cb(); } catch (e) { - if (stage.allowSkip && (e instanceof SkipError)) { + if (this._allowSkips && (e instanceof SkipError)) { if (this.status === 'passed') this.status = 'skipped'; } else if (!(e instanceof TimeoutManagerError)) { - // Do not consider timeout errors in child stages as a regular "hard error". + // Note: we handle timeout errors at the top level, so ignore them here. + // Unfortunately, we cannot ignore user errors here. Consider the following scenario: + // - locator.click times out + // - all stages containing the test function finish with TimeoutManagerError + // - test finishes, the page is closed and this triggers locator.click error + // - we would like to present the locator.click error to the user + // - therefore, we need a try/catch inside the "run with timeout" block and capture the error this._failWithError(e, true /* isHardError */, true /* retriable */); } throw e; @@ -414,17 +396,6 @@ export class TestInfoImpl implements TestInfo { }); stage.step?.complete({}); } catch (error) { - // When interrupting, we arrive here with a TimeoutManagerError, but we should not - // consider it a timeout. - if (!this._wasInterrupted && !this._didTimeout && (error instanceof TimeoutManagerError)) { - this._didTimeout = true; - const serialized = serializeError(error); - this.errors.push(serialized); - this._tracing.appendForError(serialized); - // Do not overwrite existing failure upon hook/teardown timeout. - if (this.status === 'passed' || this.status === 'skipped') - this.status = 'timedOut'; - } stage.step?.complete({ error }); throw error; } finally { @@ -435,6 +406,13 @@ export class TestInfoImpl implements TestInfo { } } + _handlePossibleTimeoutError(error: Error) { + // When interrupting, we arrive here with a TimeoutManagerError, but we should not + // consider it a timeout. + if (!this._wasInterrupted && (error instanceof TimeoutManagerError)) + this._failWithError(error, false /* isHardError */, true /* retriable */); + } + _isFailure() { return this.status !== 'skipped' && this.status !== this.expectedStatus; } diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index d90ee06547..f253e2e0a4 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -23,32 +23,30 @@ export type TimeSlot = { elapsed: number; }; -export type RunnableType = 'test' | 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | 'afterHooks' | 'slow' | 'skip' | 'fail' | 'fixme' | 'teardown'; +type RunnableType = 'test' | 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach' | 'slow' | 'skip' | 'fail' | 'fixme' | 'teardown'; export type RunnableDescription = { type: RunnableType; location?: Location; slot?: TimeSlot; // Falls back to test slot. + fixture?: FixtureDescription; }; export type FixtureDescription = { title: string; phase: 'setup' | 'teardown'; location?: Location; - slot?: TimeSlot; // Falls back to current runnable slot. + slot?: TimeSlot; // Falls back to the runnable slot. }; export class TimeoutManager { private _defaultSlot: TimeSlot; - private _defaultRunnable: RunnableDescription; private _runnable: RunnableDescription; - private _fixture: FixtureDescription | undefined; private _timeoutRunner: TimeoutRunner; constructor(timeout: number) { this._defaultSlot = { timeout, elapsed: 0 }; - this._defaultRunnable = { type: 'test', slot: this._defaultSlot }; - this._runnable = this._defaultRunnable; + this._runnable = { type: 'test' }; this._timeoutRunner = new TimeoutRunner(timeout); } @@ -59,11 +57,7 @@ export class TimeoutManager { async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { if (!runnable) return await cb(); - const existingRunnable = this._runnable; - const effectiveRunnable = { ...runnable }; - if (!effectiveRunnable.slot) - effectiveRunnable.slot = this._runnable.slot; - this._updateRunnables(effectiveRunnable, undefined); + this._updateRunnable(runnable); try { return await this._timeoutRunner.run(cb); } catch (error) { @@ -71,14 +65,10 @@ export class TimeoutManager { throw error; throw this._createTimeoutError(); } finally { - this._updateRunnables(existingRunnable, undefined); + this._updateRunnable({ type: 'test' }); } } - setCurrentFixture(fixture: FixtureDescription | undefined) { - this._updateRunnables(this._runnable, fixture); - } - defaultSlotTimings() { const slot = this._currentSlot(); slot.elapsed = this._timeoutRunner.elapsed(); @@ -100,7 +90,7 @@ export class TimeoutManager { } currentRunnableType() { - return this._runnable.type; + return this._runnable?.type || 'test'; } currentSlotDeadline() { @@ -108,15 +98,14 @@ export class TimeoutManager { } private _currentSlot() { - return this._fixture?.slot || this._runnable.slot || this._defaultSlot; + return this._runnable.fixture?.slot || this._runnable.slot || this._defaultSlot; } - private _updateRunnables(runnable: RunnableDescription, fixture: FixtureDescription | undefined) { + private _updateRunnable(runnable: RunnableDescription) { let slot = this._currentSlot(); slot.elapsed = this._timeoutRunner.elapsed(); this._runnable = runnable; - this._fixture = fixture; slot = this._currentSlot(); this._timeoutRunner.updateTimeout(slot.timeout, slot.elapsed); @@ -125,15 +114,14 @@ export class TimeoutManager { private _createTimeoutError(): Error { let message = ''; const timeout = this._currentSlot().timeout; - switch (this._runnable.type) { - case 'afterHooks': + switch (this._runnable.type || 'test') { case 'test': { - if (this._fixture) { - if (this._fixture.phase === 'setup') { - message = `Test timeout of ${timeout}ms exceeded while setting up "${this._fixture.title}".`; + if (this._runnable.fixture) { + if (this._runnable.fixture.phase === 'setup') { + message = `Test timeout of ${timeout}ms exceeded while setting up "${this._runnable.fixture.title}".`; } else { message = [ - `Test finished within timeout of ${timeout}ms, but tearing down "${this._fixture.title}" ran out of time.`, + `Test finished within timeout of ${timeout}ms, but tearing down "${this._runnable.fixture.title}" ran out of time.`, `Please allow more time for the test, since teardown is attributed towards the test timeout budget.`, ].join('\n'); } @@ -151,8 +139,8 @@ export class TimeoutManager { message = `"${this._runnable.type}" hook timeout of ${timeout}ms exceeded.`; break; case 'teardown': { - if (this._fixture) - message = `Worker teardown timeout of ${timeout}ms exceeded while ${this._fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${this._fixture.title}".`; + if (this._runnable.fixture) + message = `Worker teardown timeout of ${timeout}ms exceeded while ${this._runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${this._runnable.fixture.title}".`; else message = `Worker teardown timeout of ${timeout}ms exceeded.`; break; @@ -164,7 +152,7 @@ export class TimeoutManager { message = `"${this._runnable.type}" modifier timeout of ${timeout}ms exceeded.`; break; } - const fixtureWithSlot = this._fixture?.slot ? this._fixture : undefined; + const fixtureWithSlot = this._runnable.fixture?.slot ? this._runnable.fixture : undefined; if (fixtureWithSlot) message = `Fixture "${fixtureWithSlot.title}" timeout of ${timeout}ms exceeded during ${fixtureWithSlot.phase}.`; message = colors.red(message); diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 5cd95d77e2..8865dd2120 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -31,7 +31,7 @@ import { PoolBuilder } from '../common/poolBuilder'; import type { TestInfoError } from '../../types/test'; import type { Location } from '../../types/testReporter'; import { inheritFixutreNames } from '../common/fixtures'; -import { TimeoutManagerError } from './timeoutManager'; +import { type TimeSlot, TimeoutManagerError } from './timeoutManager'; export class WorkerMain extends ProcessRunner { private _params: WorkerInitParams; @@ -145,15 +145,10 @@ export class WorkerMain extends ProcessRunner { } private async _teardownScopes() { - // TODO: separate timeout for teardown? const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); - await fakeTestInfo._runAsStage({ title: 'teardown scopes', runnableType: 'teardown' }, async () => { - try { - await this._fixtureRunner.teardownScope('test', fakeTestInfo); - } finally { - await this._fixtureRunner.teardownScope('worker', fakeTestInfo); - } - }); + const runnable = { type: 'teardown' } as const; + await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(error => fakeTestInfo._handlePossibleTimeoutError(error)); + await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(error => fakeTestInfo._handlePossibleTimeoutError(error)); this._fatalErrors.push(...fakeTestInfo.errors); } @@ -310,8 +305,9 @@ export class WorkerMain extends ProcessRunner { this._lastRunningTests.shift(); let shouldRunAfterEachHooks = false; - await testInfo._runAsStage({ title: 'setup and test', runnableType: 'test', allowSkip: true }, async () => { - await testInfo._runAsStage({ title: 'start tracing', canTimeout: true }, async () => { + testInfo._allowSkips = true; + await testInfo._runAsStage({ title: 'setup and test' }, async () => { + await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -336,7 +332,7 @@ export class WorkerMain extends ProcessRunner { await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - await testInfo._runAsStage({ title: 'Before Hooks', stepCategory: 'hook' }, async () => { + await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => { // Run "beforeAll" hooks, unless already run during previous tests. for (const suite of suites) await this._runBeforeAllHooksForSuite(suite, testInfo); @@ -346,7 +342,7 @@ export class WorkerMain extends ProcessRunner { await this._runEachHooksForSuites(suites, 'beforeEach', testInfo); // Setup fixtures required by the test. - testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test'); + testFunctionParams = await this._fixtureRunner.resolveParametersForFunction(test.fn, testInfo, 'test', { type: 'test' }); }); if (testFunctionParams === null) { @@ -354,58 +350,54 @@ export class WorkerMain extends ProcessRunner { return; } - await testInfo._runAsStage({ title: 'test function', canTimeout: true }, async () => { + await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => { // Now run the test itself. const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); }); - }).catch(() => {}); // Ignore top-level error, we still have to run after hooks. + }).catch(error => testInfo._handlePossibleTimeoutError(error)); // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; + // No skips in after hooks. + testInfo._allowSkips = true; + // After hooks get an additional timeout. const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout); const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 }; - await testInfo._runAsStage({ - title: 'After Hooks', - stepCategory: 'hook', - runnableType: 'afterHooks', - runnableSlot: afterHooksSlot, - }, async () => { + await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => { let firstAfterHooksError: Error | undefined; - let didTimeoutInRegularCleanup = false; + let didTimeoutInAfterHooks = false; try { // Run "immediately upon test function finish" callback. - await testInfo._runAsStage({ title: 'on-test-function-finish', canTimeout: true }, async () => testInfo._onDidFinishTestFunction?.()); + await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.()); } catch (error) { if (error instanceof TimeoutManagerError) - didTimeoutInRegularCleanup = true; + didTimeoutInAfterHooks = true; firstAfterHooksError = firstAfterHooksError ?? error; } try { // Run "afterEach" hooks, unless we failed at beforeAll stage. - if (!didTimeoutInRegularCleanup && shouldRunAfterEachHooks) - await this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo); + if (!didTimeoutInAfterHooks && shouldRunAfterEachHooks) + await this._runEachHooksForSuites(reversedSuites, 'afterEach', testInfo, afterHooksSlot); } catch (error) { if (error instanceof TimeoutManagerError) - didTimeoutInRegularCleanup = true; + didTimeoutInAfterHooks = true; firstAfterHooksError = firstAfterHooksError ?? error; } try { - if (!didTimeoutInRegularCleanup) { + if (!didTimeoutInAfterHooks) { // Teardown test-scoped fixtures. Attribute to 'test' so that users understand // they should probably increase the test timeout to fix this issue. - await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test' }, async () => { - await this._fixtureRunner.teardownScope('test', testInfo); - }); + await this._fixtureRunner.teardownScope('test', testInfo, { type: 'test', slot: afterHooksSlot }); } } catch (error) { if (error instanceof TimeoutManagerError) - didTimeoutInRegularCleanup = true; + didTimeoutInAfterHooks = true; firstAfterHooksError = firstAfterHooksError ?? error; } @@ -422,51 +414,54 @@ export class WorkerMain extends ProcessRunner { } } } + if (firstAfterHooksError) + throw firstAfterHooksError; + }).catch(error => testInfo._handlePossibleTimeoutError(error)); - if (testInfo._isFailure()) - this._isStopped = true; + if (testInfo._isFailure()) + this._isStopped = true; - if (this._isStopped) { - // Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down. - // Mark as "cleaned up" early to avoid running cleanup twice. - this._didRunFullCleanup = true; + if (this._isStopped) { + // Run all remaining "afterAll" hooks and teardown all fixtures when worker is shutting down. + // Mark as "cleaned up" early to avoid running cleanup twice. + this._didRunFullCleanup = true; + + await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => { + let firstWorkerCleanupError: Error | undefined; // Give it more time for the full cleanup. const teardownSlot = { timeout: this._project.project.timeout, elapsed: 0 }; try { // Attribute to 'test' so that users understand they should probably increate the test timeout to fix this issue. - await testInfo._runAsStage({ title: 'teardown test scope', runnableType: 'test', runnableSlot: teardownSlot }, async () => { - await this._fixtureRunner.teardownScope('test', testInfo); - }); + await this._fixtureRunner.teardownScope('test', testInfo, { type: 'test', slot: teardownSlot }); } catch (error) { - firstAfterHooksError = firstAfterHooksError ?? error; + firstWorkerCleanupError = firstWorkerCleanupError ?? error; } for (const suite of reversedSuites) { try { await this._runAfterAllHooksForSuite(suite, testInfo); } catch (error) { - firstAfterHooksError = firstAfterHooksError ?? error; + firstWorkerCleanupError = firstWorkerCleanupError ?? error; } } try { // Attribute to 'teardown' because worker fixtures are not perceived as a part of a test. - await testInfo._runAsStage({ title: 'teardown worker scope', runnableType: 'teardown', runnableSlot: teardownSlot }, async () => { - await this._fixtureRunner.teardownScope('worker', testInfo); - }); + await this._fixtureRunner.teardownScope('worker', testInfo, { type: 'teardown', slot: teardownSlot }); } catch (error) { - firstAfterHooksError = firstAfterHooksError ?? error; + firstWorkerCleanupError = firstWorkerCleanupError ?? error; } - } - if (firstAfterHooksError) - throw firstAfterHooksError; - }).catch(() => {}); // Ignore top-level error. + if (firstWorkerCleanupError) + throw firstWorkerCleanupError; + }).catch(error => testInfo._handlePossibleTimeoutError(error)); + } - await testInfo._runAsStage({ title: 'stop tracing' }, async () => { + const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { await testInfo._tracing.stopIfNeeded(); - }).catch(() => {}); // Ignore top-level error. + }).catch(error => testInfo._handlePossibleTimeoutError(error)); testInfo.duration = (testInfo._timeoutManager.defaultSlotTimings().elapsed + afterHooksSlot.elapsed) | 0; @@ -517,18 +512,13 @@ export class WorkerMain extends ProcessRunner { let firstError: Error | undefined; for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { try { - // Separate time slot for each beforeAll/afterAll hook. - const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStage({ - title: hook.title, - runnableType: hook.type, - runnableSlot: timeSlot, - stepCategory: 'hook', - location: hook.location, - }, async () => { + await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + // Separate time slot for each beforeAll/afterAll hook. + const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; + const runnable = { type: hook.type, slot: timeSlot, location: hook.location }; const existingAnnotations = new Set(testInfo.annotations); try { - await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only'); + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'all-hooks-only', runnable); } finally { if (extraAnnotations) { // Inherit all annotations defined in the beforeAll/modifer to all tests in the suite. @@ -537,7 +527,7 @@ export class WorkerMain extends ProcessRunner { } // Each beforeAll/afterAll hook has its own scope for test fixtures. Attribute to the same runnable and timeSlot. // Note: we must teardown even after hook fails, because we'll run more hooks. - await this._fixtureRunner.teardownScope('test', testInfo); + await this._fixtureRunner.teardownScope('test', testInfo, runnable); } }); } catch (error) { @@ -564,19 +554,15 @@ export class WorkerMain extends ProcessRunner { await this._runAllHooksForSuite(suite, testInfo, 'afterAll'); } - private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl) { + private async _runEachHooksForSuites(suites: Suite[], type: 'beforeEach' | 'afterEach', testInfo: TestInfoImpl, slot?: TimeSlot) { // Always run all the hooks, unless one of the times out, and capture the first error. let firstError: Error | undefined; const hooks = suites.map(suite => this._collectHooksAndModifiers(suite, type, testInfo)).flat(); for (const hook of hooks) { try { - await testInfo._runAsStage({ - title: hook.title, - runnableType: hook.type, - location: hook.location, - stepCategory: 'hook', - }, async () => { - await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test'); + await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + const runnable = { type: hook.type, location: hook.location, slot }; + await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable); }); } catch (error) { if (error instanceof TimeoutManagerError) diff --git a/tests/playwright-test/hooks.spec.ts b/tests/playwright-test/hooks.spec.ts index dcc497f6dc..4d8999ba3f 100644 --- a/tests/playwright-test/hooks.spec.ts +++ b/tests/playwright-test/hooks.spec.ts @@ -526,7 +526,7 @@ test('afterAll timeout should be reported, run other afterAll hooks, and continu test.afterAll(async () => { console.log('\\n%%afterAll2'); }); - test('does not run', () => { + test('run in a different worker', () => { console.log('\\n%%test2'); }); `, diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 95450bf483..dee65c9a52 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -129,6 +129,7 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { ' fixture: context', ' fixture: request', ' apiRequestContext.dispose', + 'Worker Cleanup', ' fixture: browser', ]); }); @@ -332,6 +333,7 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve ' apiRequestContext.get', ' fixture: request', ' apiRequestContext.dispose', + 'Worker Cleanup', ' fixture: browser', ]); expect(trace1.errors).toEqual([`'oh no!'`]); @@ -667,6 +669,7 @@ test('should show non-expect error in trace', async ({ runInlineTest }, testInfo 'After Hooks', ' fixture: page', ' fixture: context', + 'Worker Cleanup', ' fixture: browser', ]); expect(trace.errors).toEqual(['ReferenceError: undefinedVariable1 is not defined']); @@ -985,6 +988,7 @@ test('should record nested steps, even after timeout', async ({ runInlineTest }, ' barPage teardown', ' step in barPage teardown', ' page.close', + 'Worker Cleanup', ' fixture: browser', ]); }); @@ -1029,6 +1033,7 @@ test('should attribute worker fixture teardown to the right test', async ({ runI expect(trace2.actionTree).toEqual([ 'Before Hooks', 'After Hooks', + 'Worker Cleanup', ' fixture: foo', ' step in foo teardown', ]); diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index aaa24f7fbd..3c0f81e1f0 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -277,6 +277,8 @@ for (const useIntermediateMergeReport of [false, true] as const) { `end {\"title\":\"expect.toBeTruthy\",\"category\":\"expect\",\"error\":{\"message\":\"Error: \\u001b[2mexpect(\\u001b[22m\\u001b[31mreceived\\u001b[39m\\u001b[2m).\\u001b[22mtoBeTruthy\\u001b[2m()\\u001b[22m\\n\\nReceived: \\u001b[31mfalse\\u001b[39m\",\"stack\":\"\",\"location\":\"\",\"snippet\":\"\"}}`, `begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`, `end {\"title\":\"After Hooks\",\"category\":\"hook\"}`, + `begin {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, + `end {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, `begin {\"title\":\"Before Hooks\",\"category\":\"hook\"}`, `end {\"title\":\"Before Hooks\",\"category\":\"hook\"}`, `begin {\"title\":\"expect.not.toBeTruthy\",\"category\":\"expect\"}`, @@ -460,9 +462,11 @@ for (const useIntermediateMergeReport of [false, true] as const) { `end {\"title\":\"fixture: page\",\"category\":\"fixture\"}`, `begin {\"title\":\"fixture: context\",\"category\":\"fixture\"}`, `end {\"title\":\"fixture: context\",\"category\":\"fixture\"}`, + `end {\"title\":\"After Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: page\",\"category\":\"fixture\"},{\"title\":\"fixture: context\",\"category\":\"fixture\"}]}`, + `begin {\"title\":\"Worker Cleanup\",\"category\":\"hook\"}`, `begin {\"title\":\"fixture: browser\",\"category\":\"fixture\"}`, `end {\"title\":\"fixture: browser\",\"category\":\"fixture\"}`, - `end {\"title\":\"After Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: page\",\"category\":\"fixture\"},{\"title\":\"fixture: context\",\"category\":\"fixture\"},{\"title\":\"fixture: browser\",\"category\":\"fixture\"}]}`, + `end {\"title\":\"Worker Cleanup\",\"category\":\"hook\",\"steps\":[{\"title\":\"fixture: browser\",\"category\":\"fixture\"}]}`, ]); }); diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index c00521e9ae..4aa07c6a52 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -261,6 +261,10 @@ test('should report before hooks step error', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: expect.any(Object) } @@ -345,6 +349,12 @@ test('should not report nested after hooks', async ({ runInlineTest }) => { category: 'fixture', title: 'fixture: context', }, + ], + }, + { + category: 'hook', + title: 'Worker Cleanup', + steps: [ { category: 'fixture', title: 'fixture: browser', @@ -577,6 +587,10 @@ test('should report custom expect steps', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: expect.any(Object) } @@ -658,6 +672,7 @@ test('should mark step as failed when soft expect fails', async ({ runInlineTest location: { file: 'a.test.ts', line: expect.any(Number), column: expect.any(Number) } }, { title: 'After Hooks', category: 'hook' }, + { title: 'Worker Cleanup', category: 'hook' }, { error: expect.any(Object) } ]); }); @@ -972,6 +987,12 @@ test('should not mark page.close as failed when page.click fails', async ({ runI }, ], }, + ], + }, + { + category: 'hook', + title: 'Worker Cleanup', + steps: [ { category: 'fixture', title: 'fixture: browser', @@ -1168,6 +1189,10 @@ test('should show final toPass error', async ({ runInlineTest }) => { title: 'After Hooks', category: 'hook', }, + { + title: 'Worker Cleanup', + category: 'hook', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1255,6 +1280,10 @@ test('should propagate nested soft errors', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1348,6 +1377,10 @@ test('should not propagate nested hard errors', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1404,6 +1437,10 @@ test('should step w/o box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), @@ -1453,6 +1490,10 @@ test('should step w/ box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('expect(received).toBe(expected)'), @@ -1502,6 +1543,10 @@ test('should soft step w/ box', async ({ runInlineTest }) => { category: 'hook', title: 'After Hooks', }, + { + category: 'hook', + title: 'Worker Cleanup', + }, { error: { message: expect.stringContaining('Error: expect(received).toBe(expected)'), diff --git a/tests/playwright-test/timeout.spec.ts b/tests/playwright-test/timeout.spec.ts index bed9429ad3..0ffa2164ce 100644 --- a/tests/playwright-test/timeout.spec.ts +++ b/tests/playwright-test/timeout.spec.ts @@ -479,3 +479,38 @@ test('beforeEach timeout should prevent others from running', async ({ runInline expect(result.failed).toBe(1); expect(result.outputLines).toEqual(['beforeEach1', 'afterEach']); }); + +test('should report up to 3 timeout errors', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base } from '@playwright/test'; + + const test = base.extend<{}, { autoWorker: void }>({ + autoWorker: [ + async ({}, use) => { + await use(); + await new Promise(() => {}); + }, + { scope: 'worker', auto: true }, + ], + }) + + test('test1', async () => { + await new Promise(() => {}); + }); + + test.afterEach(async () => { + await new Promise(() => {}); + }); + + test.afterAll(async () => { + await new Promise(() => {}); + }); + ` + }, { timeout: 1000 }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Test timeout of 1000ms exceeded.'); + expect(result.output).toContain('Test timeout of 1000ms exceeded while running "afterEach" hook.'); + expect(result.output).toContain('Worker teardown timeout of 1000ms exceeded while tearing down "autoWorker".'); +}); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 215afe1d2b..194e89afba 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -96,6 +96,7 @@ test('should merge screenshot assertions', async ({ runUITest }, testInfo) => { /expect.toHaveScreenshot[\d.]+m?s/, /attach "trace-test-1-actual.png/, /After Hooks[\d.]+m?s/, + /Worker Cleanup[\d.]+m?s/, ]); }); From 7f6f17d1c343f88400c498fd1c3defbd7f420056 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Sun, 10 Mar 2024 11:11:35 -0700 Subject: [PATCH 077/141] chore: simplify toMatchSnapshot names calculation (#29862) Reference https://github.com/microsoft/playwright/issues/29719 --- .../src/matchers/toMatchSnapshot.ts | 38 +++++++++---------- packages/playwright/src/util.ts | 14 ++++--- tests/playwright-test/golden.spec.ts | 4 +- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 449c7349f0..317a0144d9 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -22,7 +22,8 @@ import { getComparator, sanitizeForFilePath, zones } from 'playwright-core/lib/u import { addSuffixToFilePath, trimLongString, callLogText, - expectTypes } from '../util'; + expectTypes, + sanitizeFilePathBeforeExtension } from '../util'; import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; @@ -101,9 +102,9 @@ class SnapshotHelper { name = nameOrOptions; this.options = { ...optOptions }; } else { - name = nameOrOptions.name; - this.options = { ...nameOrOptions }; - delete (this.options as any).name; + const { name: nameFromOptions, ...options } = nameOrOptions; + this.options = options; + name = nameFromOptions; } let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; @@ -121,25 +122,34 @@ class SnapshotHelper { // // noop // expect.toMatchSnapshot('a.png') - let actualModifier = ''; + let inputPathSegments: string[]; if (!name) { const fullTitleWithoutSpec = [ ...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex, ].join(' '); name = sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.' + anonymousSnapshotExtension; + inputPathSegments = [name]; this.snapshotName = name; } else { + // We intentionally do not sanitize user-provided array of segments, but for backwards + // compatibility we do sanitize the name if it is a single string. + // See https://github.com/microsoft/playwright/pull/9156 + inputPathSegments = Array.isArray(name) ? name : [sanitizeFilePathBeforeExtension(name)]; const joinedName = Array.isArray(name) ? name.join(path.sep) : name; snapshotNames.namedSnapshotIndex[joinedName] = (snapshotNames.namedSnapshotIndex[joinedName] || 0) + 1; const index = snapshotNames.namedSnapshotIndex[joinedName]; - if (index > 1) { - actualModifier = `-${index - 1}`; + if (index > 1) this.snapshotName = `${joinedName}-${index - 1}`; - } else { + else this.snapshotName = joinedName; - } } + this.snapshotPath = testInfo.snapshotPath(...inputPathSegments); + this.legacyExpectedPath = addSuffixToFilePath(testInfo._getOutputPath(...inputPathSegments), '-expected'); + const outputFile = testInfo._getOutputPath(sanitizeFilePathBeforeExtension(this.snapshotName)); + this.previousPath = addSuffixToFilePath(outputFile, '-previous'); + this.actualPath = addSuffixToFilePath(outputFile, '-actual'); + this.diffPath = addSuffixToFilePath(outputFile, '-diff'); const filteredConfigOptions = { ...configOptions }; for (const prop of NonConfigProperties) @@ -161,16 +171,6 @@ class SnapshotHelper { if (this.options.maxDiffPixelRatio !== undefined && (this.options.maxDiffPixelRatio < 0 || this.options.maxDiffPixelRatio > 1)) throw new Error('`maxDiffPixelRatio` option value must be between 0 and 1'); - // sanitizes path if string - const inputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, '', undefined, true)]; - const outputPathSegments = Array.isArray(name) ? name : [addSuffixToFilePath(name, actualModifier, undefined, true)]; - this.snapshotPath = testInfo.snapshotPath(...inputPathSegments); - const inputFile = testInfo._getOutputPath(...inputPathSegments); - const outputFile = testInfo._getOutputPath(...outputPathSegments); - this.legacyExpectedPath = addSuffixToFilePath(inputFile, '-expected'); - this.previousPath = addSuffixToFilePath(outputFile, '-previous'); - this.actualPath = addSuffixToFilePath(outputFile, '-actual'); - this.diffPath = addSuffixToFilePath(outputFile, '-diff'); this.matcherName = matcherName; this.locator = locator; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 505a59c6b6..d67649276a 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -195,12 +195,16 @@ export function trimLongString(s: string, length = 100) { return s.substring(0, start) + middle + s.slice(-end); } -export function addSuffixToFilePath(filePath: string, suffix: string, customExtension?: string, sanitize = false): string { - const dirname = path.dirname(filePath); +export function addSuffixToFilePath(filePath: string, suffix: string): string { const ext = path.extname(filePath); - const name = path.basename(filePath, ext); - const base = path.join(dirname, name); - return (sanitize ? sanitizeForFilePath(base) : base) + suffix + (customExtension || ext); + const base = filePath.substring(0, filePath.length - ext.length); + return base + suffix + ext; +} + +export function sanitizeFilePathBeforeExtension(filePath: string): string { + const ext = path.extname(filePath); + const base = filePath.substring(0, filePath.length - ext.length); + return sanitizeForFilePath(base) + ext; } /** diff --git a/tests/playwright-test/golden.spec.ts b/tests/playwright-test/golden.spec.ts index 2cba3b0623..39efa3c0b8 100644 --- a/tests/playwright-test/golden.spec.ts +++ b/tests/playwright-test/golden.spec.ts @@ -907,12 +907,12 @@ test('should attach expected/actual/diff with snapshot path', async ({ runInline { name: 'test/path/snapshot-actual.png', contentType: 'image/png', - path: 'a-is-a-test/test/path/snapshot-actual.png' + path: 'a-is-a-test/test-path-snapshot-actual.png' }, { name: 'test/path/snapshot-diff.png', contentType: 'image/png', - path: 'a-is-a-test/test/path/snapshot-diff.png' + path: 'a-is-a-test/test-path-snapshot-diff.png' } ]); }); From 94e61fc95a21537508f2289b9cdad035740b64d6 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 11 Mar 2024 17:09:26 +0100 Subject: [PATCH 078/141] test: bump ct typescript version (#29855) --- tests/components/ct-react-vite/package.json | 2 +- tests/components/ct-solid/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/ct-react-vite/package.json b/tests/components/ct-react-vite/package.json index 1e322f4b9f..404e673102 100644 --- a/tests/components/ct-react-vite/package.json +++ b/tests/components/ct-react-vite/package.json @@ -17,7 +17,7 @@ "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", "@vitejs/plugin-react": "^4.2.1", - "typescript": "^4.5.4", + "typescript": "^5.2.2", "vite": "^5.0.11" } } diff --git a/tests/components/ct-solid/package.json b/tests/components/ct-solid/package.json index 7d95f07a32..654f2002e7 100644 --- a/tests/components/ct-solid/package.json +++ b/tests/components/ct-solid/package.json @@ -15,7 +15,7 @@ "solid-js": "^1.7.3" }, "devDependencies": { - "typescript": "^4.7.4", + "typescript": "^5.2.2", "vite": "^5.0.11", "vite-plugin-solid": "^2.6.1" } From 59228f19cee6a75f55ad2177ee978f3df8ff2887 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 11 Mar 2024 10:11:16 -0700 Subject: [PATCH 079/141] fix: tty.WriteStream method stubs for process.stdout/stderr (#29865) Fixes #29839 --- packages/playwright/src/common/process.ts | 22 ++++++++++++++++++ tests/playwright-test/stdio.spec.ts | 27 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index f082e958c2..23de620649 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -131,4 +131,26 @@ function setTtyParams(stream: WriteStream, params: TtyParams) { count = 16; return count <= 2 ** params.colorDepth; })as any; + + // Stubs for the rest of the methods to avoid exceptions in user code. + stream.clearLine = (dir: any, callback?: () => void) => { + callback?.(); + return true; + }; + stream.clearScreenDown = (callback?: () => void) => { + callback?.(); + return true; + }; + (stream as any).cursorTo = (x: number, y?: number | (() => void), callback?: () => void) => { + if (callback) + callback(); + else if (y instanceof Function) + y(); + return true; + }; + stream.moveCursor = (dx: number, dy: number, callback?: () => void) => { + callback?.(); + return true; + }; + stream.getWindowSize = () => [stream.columns, stream.rows]; } diff --git a/tests/playwright-test/stdio.spec.ts b/tests/playwright-test/stdio.spec.ts index 251cf527bb..e0609b1ac6 100644 --- a/tests/playwright-test/stdio.spec.ts +++ b/tests/playwright-test/stdio.spec.ts @@ -128,3 +128,30 @@ test('should not throw type error when using assert', async ({ runInlineTest }) expect(result.output).not.toContain(`TypeError: process.stderr.hasColors is not a function`); expect(result.output).toContain(`AssertionError`); }); + +test('should provide stubs for tty.WriteStream methods on process.stdout/stderr', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29839' }); + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + import type * as tty from 'tty'; + async function checkMethods(stream: tty.WriteStream) { + expect(stream.isTTY).toBe(true); + await new Promise(r => stream.clearLine(-1, r));; + await new Promise(r => stream.clearScreenDown(r));; + await new Promise(r => stream.cursorTo(0, 0, r));; + await new Promise(r => stream.cursorTo(0, r));; + await new Promise(r => stream.moveCursor(1, 1, r));; + expect(stream.getWindowSize()).toEqual([stream.columns, stream.rows]); + // getColorDepth() and hasColors() are covered in other tests. + } + test('process.stdout implementd tty.WriteStream methods', () => { + checkMethods(process.stdout); + }); + test('process.stderr implementd tty.WriteStream methods', () => { + checkMethods(process.stderr); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); From 8f4c2f714df1880e888400d9bc186c934573e96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Greffier?= Date: Mon, 11 Mar 2024 19:21:41 +0100 Subject: [PATCH 080/141] docs(java): update JUnit examples with new fixtures (#29406) Documentation for JUnit integration aka JUnit fixtures https://github.com/microsoft/playwright-java/issues/1369 --- docs/src/accessibility-testing-java.md | 2 + docs/src/api-testing-java.md | 2 + docs/src/junit-java.md | 182 +++++++++++++++++++++++++ docs/src/running-tests-java.md | 2 + docs/src/test-runners-java.md | 2 + 5 files changed, 190 insertions(+) create mode 100644 docs/src/junit-java.md diff --git a/docs/src/accessibility-testing-java.md b/docs/src/accessibility-testing-java.md index a14064452c..15bb998481 100644 --- a/docs/src/accessibility-testing-java.md +++ b/docs/src/accessibility-testing-java.md @@ -238,3 +238,5 @@ public class HomepageTests extends AxeTestFixtures { } } ``` + +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. diff --git a/docs/src/api-testing-java.md b/docs/src/api-testing-java.md index 80faa968d4..41265776a5 100644 --- a/docs/src/api-testing-java.md +++ b/docs/src/api-testing-java.md @@ -375,6 +375,8 @@ public class TestGitHubAPI { } ``` +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ## Prepare server state via API calls The following test creates a new issue via API and then navigates to the list of all issues in the diff --git a/docs/src/junit-java.md b/docs/src/junit-java.md new file mode 100644 index 0000000000..99753f082c --- /dev/null +++ b/docs/src/junit-java.md @@ -0,0 +1,182 @@ +--- +id: junit +title: "JUnit (experimental)" +--- + +## Introduction + +With a few lines of code, you can hook up Playwright to your favorite Java test runner. + +In [JUnit](https://junit.org/junit5/), you can use Playwright [fixtures](./junit.md#fixtures) to automatically initialize [Playwright], [Browser], [BrowserContext] or [Page]. In the example below, all three test methods use the same +[Browser]. Each test uses its own [BrowserContext] and [Page]. + + + +```java +package org.example; + +import com.microsoft.playwright.junit.UsePlaywright; +import com.microsoft.playwright.Page; + +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@UsePlaywright +public class TestExample { + + @Test + void shouldClickButton(Page page) { + page.navigate("data:text/html,"); + page.locator("button").click(); + assertEquals("Clicked", page.evaluate("result")); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertTrue((Boolean) page.evaluate("() => window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertEquals("https://en.wikipedia.org/wiki/Playwright", page.url()); + } +} +``` + +## Fixtures + +Simply add JUnit annotation `@UsePlaywright` to your test classes to enable Playwright fixtures. Test fixtures are used to establish environment for each test, giving the test everything it needs and nothing else. + +```java +@UsePlaywright +public class TestExample { + + @Test + void basicTest(Page page) { + page.navigate("https://playwright.dev/"); + + assertThat(page).hasTitle(Pattern.compile("Playwright")); + } +} +``` + +The `Page page` argument tells JUnit to setup the `page` fixture and provide it to your test method. + +Here is a list of the pre-defined fixtures: + +|Fixture |Type |Description | +|:-------------|:------------------|:--------------------------------| +|page |[Page] |Isolated page for this test run.| +|browserContext|[BrowserContext] |Isolated context for this test run. The `page` fixture belongs to this context as well.| +|browser |[Browser] |Browsers are shared across tests to optimize resources.| +|playwright |[Playwright] |Playwright instance is shared between tests running on the same thread.| +|request |[APIRequestContext]|Isolated APIRequestContext for this test run. Learn how to do [API testing](./api-testing).| + +## Customizing options + +To customize fixture options, you should implement an `OptionsFactory` and specify the class in the `@UsePlaywright()` annotation. + +You can easily override launch options for [`method: BrowserType.launch`], or context options for [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. See the following example: + +```java +import com.microsoft.playwright.junit.Options; +import com.microsoft.playwright.junit.OptionsFactory; +import com.microsoft.playwright.junit.UsePlaywright; + +@UsePlaywright(MyTest.CustomOptions.class) +public class MyTest { + + public static class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + return new Options() + .setHeadless(false) + .setContextOption(new Browser.NewContextOptions() + .setBaseURL("https://github.com")) + .setApiRequestOptions(new APIRequest.NewContextOptions() + .setBaseURL("https://playwright.dev")); + } + } + + @Test + public void testWithCustomOptions(Page page, APIRequestContext request) { + page.navigate("/"); + assertThat(page).hasURL(Pattern.compile("github")); + + APIResponse response = request.get("/"); + assertTrue(response.text().contains("Playwright")); + } +} +``` + +## Running Tests in Parallel + +By default JUnit will run all tests sequentially on a single thread. Since JUnit 5.3 you can change this behavior to run tests in parallel +to speed up execution (see [this page](https://junit.org/junit5/docs/snapshot/user-guide/index.html#writing-tests-parallel-execution)). +Since it is not safe to use same Playwright objects from multiple threads without extra synchronization we recommend you create Playwright +instance per thread and use it on that thread exclusively. Here is an example how to run multiple test classes in parallel. + +```java +@UsePlaywright +class Test1 { + @Test + void shouldClickButton(Page page) { + page.navigate("data:text/html,"); + page.locator("button").click(); + assertEquals("Clicked", page.evaluate("result")); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertTrue((Boolean) page.evaluate("() => window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertEquals("https://en.wikipedia.org/wiki/Playwright", page.url()); + } +} + +@UsePlaywright +class Test2 { + @Test + void shouldReturnInnerHTML(Page page) { + page.setContent("
hello
"); + assertEquals("hello", page.innerHTML("css=div")); + } + + @Test + void shouldClickButton(Page page) { + Page popup = page.waitForPopup(() -> { + page.evaluate("window.open('about:blank');"); + }); + assertEquals("about:blank", popup.url()); + } +} +``` + + +Configure JUnit to run tests in each class sequentially and run multiple classes on parallel threads (with max +number of thread equal to 1/2 of the number of CPU cores): + +```bash +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.mode.default = same_thread +junit.jupiter.execution.parallel.mode.classes.default = concurrent +junit.jupiter.execution.parallel.config.strategy=dynamic +junit.jupiter.execution.parallel.config.dynamic.factor=0.5 +``` diff --git a/docs/src/running-tests-java.md b/docs/src/running-tests-java.md index d769a73e51..69943f5470 100644 --- a/docs/src/running-tests-java.md +++ b/docs/src/running-tests-java.md @@ -83,6 +83,8 @@ public class TestExample { See [here](./test-runners.md) for further details on how to run tests in parallel, etc. +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ## What's Next - [Debugging tests](./debug.md) diff --git a/docs/src/test-runners-java.md b/docs/src/test-runners-java.md index f9c808b5b7..d29da2e8ba 100644 --- a/docs/src/test-runners-java.md +++ b/docs/src/test-runners-java.md @@ -87,6 +87,8 @@ public class TestExample { } ``` +See experimental [JUnit integration](./junit.md) to automatically initialize Playwright objects and more. + ### Running Tests in Parallel By default JUnit will run all tests sequentially on a single thread. Since JUnit 5.3 you can change this behavior to run tests in parallel From 88e80cf948f76e1ad9a1fac2cfc3872232641c19 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 11 Mar 2024 15:43:50 -0700 Subject: [PATCH 081/141] chore(test runner): simplify TimeoutManager and TimeoutRunner (#29863) --- .../src/utils/timeoutRunner.ts | 112 ++-------------- packages/playwright/src/index.ts | 10 +- packages/playwright/src/worker/testInfo.ts | 16 ++- .../playwright/src/worker/timeoutManager.ts | 126 ++++++++++-------- packages/playwright/src/worker/workerMain.ts | 4 +- 5 files changed, 102 insertions(+), 166 deletions(-) diff --git a/packages/playwright-core/src/utils/timeoutRunner.ts b/packages/playwright-core/src/utils/timeoutRunner.ts index dd60551f62..622019565a 100644 --- a/packages/playwright-core/src/utils/timeoutRunner.ts +++ b/packages/playwright-core/src/utils/timeoutRunner.ts @@ -14,108 +14,22 @@ * limitations under the License. */ -import { ManualPromise } from './manualPromise'; import { monotonicTime } from './'; -export class TimeoutRunnerError extends Error {} - -type TimeoutRunnerData = { - lastElapsedSync: number, - timer: NodeJS.Timeout | undefined, - timeoutPromise: ManualPromise, -}; - -export const MaxTime = 2147483647; // 2^31-1 - -export class TimeoutRunner { - private _running: TimeoutRunnerData | undefined; - private _timeout: number; - private _elapsed: number; - private _deadline = MaxTime; - - constructor(timeout: number) { - this._timeout = timeout; - this._elapsed = 0; - } - - async run(cb: () => Promise): Promise { - const running = this._running = { - lastElapsedSync: monotonicTime(), - timer: undefined, - timeoutPromise: new ManualPromise(), - }; - try { - this._updateTimeout(running, this._timeout); - const resultPromise = Promise.race([ - cb(), - running.timeoutPromise - ]); - return await resultPromise; - } finally { - this._updateTimeout(running, 0); - if (this._running === running) - this._running = undefined; - } - } - - interrupt() { - if (this._running) - this._updateTimeout(this._running, -1); - } - - elapsed() { - this._syncElapsedAndStart(); - return this._elapsed; - } - - deadline(): number { - return this._deadline; - } - - updateTimeout(timeout: number, elapsed?: number) { - this._timeout = timeout; - if (elapsed !== undefined) { - this._syncElapsedAndStart(); - this._elapsed = elapsed; - } - if (this._running) - this._updateTimeout(this._running, timeout); - } - - private _syncElapsedAndStart() { - if (this._running) { - const now = monotonicTime(); - this._elapsed += now - this._running.lastElapsedSync; - this._running.lastElapsedSync = now; - } - } - - private _updateTimeout(running: TimeoutRunnerData, timeout: number) { - if (running.timer) { - clearTimeout(running.timer); - running.timer = undefined; - } - this._syncElapsedAndStart(); - this._deadline = timeout ? monotonicTime() + timeout : MaxTime; - if (timeout === 0) - return; - timeout = timeout - this._elapsed; - if (timeout <= 0) - running.timeoutPromise.reject(new TimeoutRunnerError()); - else - running.timer = setTimeout(() => running.timeoutPromise.reject(new TimeoutRunnerError()), timeout); - } -} - export async function raceAgainstDeadline(cb: () => Promise, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> { - const runner = new TimeoutRunner((deadline || MaxTime) - monotonicTime()); - try { - return { result: await runner.run(cb), timedOut: false }; - } catch (e) { - if (e instanceof TimeoutRunnerError) - return { timedOut: true }; - throw e; - } + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then(result => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>(resolve => { + const kMaxDeadline = 2147483647; // 2^31-1 + const timeout = (deadline || kMaxDeadline) - monotonicTime(); + timer = setTimeout(() => resolve({ timedOut: true }), timeout); + }), + ]).finally(() => { + clearTimeout(timer); + }); } export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 59b70a1f62..2c105a2640 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -302,8 +302,8 @@ const playwrightFixtures: Fixtures = ({ const contexts = new Map(); await use(async options => { - const hook = hookType(testInfoImpl); - if (hook) { + const hook = testInfoImpl._currentHookType(); + if (hook === 'beforeAll' || hook === 'afterAll') { throw new Error([ `"context" and "page" fixtures are not supported in "${hook}" since they are created on a per-test basis.`, `If you would like to reuse a single page between tests, create context manually with browser.newContext(). See https://aka.ms/playwright/reuse-page for details.`, @@ -396,12 +396,6 @@ const playwrightFixtures: Fixtures = ({ }, }); -function hookType(testInfo: TestInfoImpl): 'beforeAll' | 'afterAll' | undefined { - const type = testInfo._timeoutManager.currentRunnableType(); - if (type === 'beforeAll' || type === 'afterAll') - return type; -} - type StackFrame = { file: string, line?: number, diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 372f01d93f..e3ec7a46c1 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -16,11 +16,11 @@ import fs from 'fs'; import path from 'path'; -import { MaxTime, captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; +import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; -import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; +import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; import type { RunnableDescription } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; @@ -113,7 +113,7 @@ export class TestInfoImpl implements TestInfo { } get timeout(): number { - return this._timeoutManager.defaultSlotTimings().timeout; + return this._timeoutManager.defaultSlot().timeout; } set timeout(timeout: number) { @@ -122,7 +122,7 @@ export class TestInfoImpl implements TestInfo { _deadlineForMatcher(timeout: number): { deadline: number, timeoutMessage: string } { const startTime = monotonicTime(); - const matcherDeadline = timeout ? startTime + timeout : MaxTime; + const matcherDeadline = timeout ? startTime + timeout : kMaxDeadline; const testDeadline = this._timeoutManager.currentSlotDeadline() - 250; const matcherMessage = `Timeout ${timeout}ms exceeded while waiting on the predicate`; const testMessage = `Test timeout of ${this.timeout}ms exceeded`; @@ -417,6 +417,14 @@ export class TestInfoImpl implements TestInfo { return this.status !== 'skipped' && this.status !== this.expectedStatus; } + _currentHookType() { + for (let i = this._stages.length - 1; i >= 0; i--) { + const type = this._stages[i].runnable?.type; + if (type && ['beforeAll', 'afterAll', 'beforeEach', 'afterEach'].includes(type)) + return type; + } + } + // ------------ TestInfo methods ------------ async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index f253e2e0a4..6287bc232c 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -15,7 +15,7 @@ */ import { colors } from 'playwright-core/lib/utilsBundle'; -import { TimeoutRunner, TimeoutRunnerError } from 'playwright-core/lib/utils'; +import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils'; import type { Location } from '../../types/testReporter'; export type TimeSlot = { @@ -39,89 +39,109 @@ export type FixtureDescription = { slot?: TimeSlot; // Falls back to the runnable slot. }; +type Running = { + runnable: RunnableDescription; + slot: TimeSlot; + start: number; + deadline: number; + timer: NodeJS.Timeout | undefined; + timeoutPromise: ManualPromise; +}; +export const kMaxDeadline = 2147483647; // 2^31-1 + export class TimeoutManager { private _defaultSlot: TimeSlot; - private _runnable: RunnableDescription; - private _timeoutRunner: TimeoutRunner; + private _running?: Running; constructor(timeout: number) { this._defaultSlot = { timeout, elapsed: 0 }; - this._runnable = { type: 'test' }; - this._timeoutRunner = new TimeoutRunner(timeout); } interrupt() { - this._timeoutRunner.interrupt(); + if (this._running) + this._running.timeoutPromise.reject(this._createTimeoutError(this._running)); } async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { if (!runnable) return await cb(); - this._updateRunnable(runnable); + if (this._running) + throw new Error(`Internal error: duplicate runnable`); + const running = this._running = { + runnable, + slot: runnable.fixture?.slot || runnable.slot || this._defaultSlot, + start: monotonicTime(), + deadline: kMaxDeadline, + timer: undefined, + timeoutPromise: new ManualPromise(), + }; try { - return await this._timeoutRunner.run(cb); - } catch (error) { - if (!(error instanceof TimeoutRunnerError)) - throw error; - throw this._createTimeoutError(); + this._updateTimeout(running); + return await Promise.race([ + cb(), + running.timeoutPromise, + ]); } finally { - this._updateRunnable({ type: 'test' }); + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + running.slot.elapsed += monotonicTime() - running.start; + this._running = undefined; } } - defaultSlotTimings() { - const slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); + private _updateTimeout(running: Running) { + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + if (!running.slot.timeout) { + running.deadline = kMaxDeadline; + return; + } + running.deadline = running.start + (running.slot.timeout - running.slot.elapsed); + const timeout = running.deadline - monotonicTime(); + if (timeout <= 0) + running.timeoutPromise.reject(this._createTimeoutError(running)); + else + running.timer = setTimeout(() => running.timeoutPromise.reject(this._createTimeoutError(running)), timeout); + } + + defaultSlot() { return this._defaultSlot; } slow() { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; slot.timeout = slot.timeout * 3; - this._timeoutRunner.updateTimeout(slot.timeout); + if (this._running) + this._updateTimeout(this._running); } setTimeout(timeout: number) { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; if (!slot.timeout) return; // Zero timeout means some debug mode - do not set a timeout. slot.timeout = timeout; - this._timeoutRunner.updateTimeout(timeout); - } - - currentRunnableType() { - return this._runnable?.type || 'test'; + if (this._running) + this._updateTimeout(this._running); } currentSlotDeadline() { - return this._timeoutRunner.deadline(); + return this._running ? this._running.deadline : kMaxDeadline; } - private _currentSlot() { - return this._runnable.fixture?.slot || this._runnable.slot || this._defaultSlot; - } - - private _updateRunnable(runnable: RunnableDescription) { - let slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); - - this._runnable = runnable; - - slot = this._currentSlot(); - this._timeoutRunner.updateTimeout(slot.timeout, slot.elapsed); - } - - private _createTimeoutError(): Error { + private _createTimeoutError(running: Running): Error { let message = ''; - const timeout = this._currentSlot().timeout; - switch (this._runnable.type || 'test') { + const timeout = running.slot.timeout; + const runnable = running.runnable; + switch (runnable.type) { case 'test': { - if (this._runnable.fixture) { - if (this._runnable.fixture.phase === 'setup') { - message = `Test timeout of ${timeout}ms exceeded while setting up "${this._runnable.fixture.title}".`; + if (runnable.fixture) { + if (runnable.fixture.phase === 'setup') { + message = `Test timeout of ${timeout}ms exceeded while setting up "${runnable.fixture.title}".`; } else { message = [ - `Test finished within timeout of ${timeout}ms, but tearing down "${this._runnable.fixture.title}" ran out of time.`, + `Test finished within timeout of ${timeout}ms, but tearing down "${runnable.fixture.title}" ran out of time.`, `Please allow more time for the test, since teardown is attributed towards the test timeout budget.`, ].join('\n'); } @@ -132,15 +152,15 @@ export class TimeoutManager { } case 'afterEach': case 'beforeEach': - message = `Test timeout of ${timeout}ms exceeded while running "${this._runnable.type}" hook.`; + message = `Test timeout of ${timeout}ms exceeded while running "${runnable.type}" hook.`; break; case 'beforeAll': case 'afterAll': - message = `"${this._runnable.type}" hook timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" hook timeout of ${timeout}ms exceeded.`; break; case 'teardown': { - if (this._runnable.fixture) - message = `Worker teardown timeout of ${timeout}ms exceeded while ${this._runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${this._runnable.fixture.title}".`; + if (runnable.fixture) + message = `Worker teardown timeout of ${timeout}ms exceeded while ${runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${runnable.fixture.title}".`; else message = `Worker teardown timeout of ${timeout}ms exceeded.`; break; @@ -149,14 +169,14 @@ export class TimeoutManager { case 'slow': case 'fixme': case 'fail': - message = `"${this._runnable.type}" modifier timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" modifier timeout of ${timeout}ms exceeded.`; break; } - const fixtureWithSlot = this._runnable.fixture?.slot ? this._runnable.fixture : undefined; + const fixtureWithSlot = runnable.fixture?.slot ? runnable.fixture : undefined; if (fixtureWithSlot) message = `Fixture "${fixtureWithSlot.title}" timeout of ${timeout}ms exceeded during ${fixtureWithSlot.phase}.`; message = colors.red(message); - const location = (fixtureWithSlot || this._runnable).location; + const location = (fixtureWithSlot || runnable).location; const error = new TimeoutManagerError(message); error.name = ''; // Include location for hooks, modifiers and fixtures to distinguish between them. diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 8865dd2120..44a2c451f2 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -358,7 +358,7 @@ export class WorkerMain extends ProcessRunner { }).catch(error => testInfo._handlePossibleTimeoutError(error)); // Update duration, so it is available in fixture teardown and afterEach hooks. - testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; + testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; // No skips in after hooks. testInfo._allowSkips = true; @@ -463,7 +463,7 @@ export class WorkerMain extends ProcessRunner { await testInfo._tracing.stopIfNeeded(); }).catch(error => testInfo._handlePossibleTimeoutError(error)); - testInfo.duration = (testInfo._timeoutManager.defaultSlotTimings().elapsed + afterHooksSlot.elapsed) | 0; + testInfo.duration = (testInfo._timeoutManager.defaultSlot().elapsed + afterHooksSlot.elapsed) | 0; this._currentTest = null; setCurrentTestInfo(null); From 70f49f6ff1a994d5200876cf16d9224ffa260c72 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 11 Mar 2024 16:35:19 -0700 Subject: [PATCH 082/141] docs: junit integration release notes (#29885) --- docs/src/junit-java.md | 16 +++---- docs/src/release-notes-java.md | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/src/junit-java.md b/docs/src/junit-java.md index 99753f082c..7ea43399dd 100644 --- a/docs/src/junit-java.md +++ b/docs/src/junit-java.md @@ -15,17 +15,15 @@ In [JUnit](https://junit.org/junit5/), you can use Playwright [fixtures](./junit ```java package org.example; -import com.microsoft.playwright.junit.UsePlaywright; import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.*; - +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; @UsePlaywright public class TestExample { - @Test void shouldClickButton(Page page) { page.navigate("data:text/html,"); @@ -37,7 +35,7 @@ public class TestExample { void shouldCheckTheBox(Page page) { page.setContent(""); page.locator("input").check(); - assertTrue((Boolean) page.evaluate("() => window['checkbox'].checked")); + assertEquals(true, page.evaluate("window['checkbox'].checked")); } @Test @@ -46,7 +44,7 @@ public class TestExample { page.locator("input[name=\"search\"]").click(); page.locator("input[name=\"search\"]").fill("playwright"); page.locator("input[name=\"search\"]").press("Enter"); - assertEquals("https://en.wikipedia.org/wiki/Playwright", page.url()); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); } } ``` @@ -138,7 +136,7 @@ class Test1 { void shouldCheckTheBox(Page page) { page.setContent(""); page.locator("input").check(); - assertTrue((Boolean) page.evaluate("() => window['checkbox'].checked")); + assertEquals(true, page.evaluate("window['checkbox'].checked")); } @Test @@ -147,7 +145,7 @@ class Test1 { page.locator("input[name=\"search\"]").click(); page.locator("input[name=\"search\"]").fill("playwright"); page.locator("input[name=\"search\"]").press("Enter"); - assertEquals("https://en.wikipedia.org/wiki/Playwright", page.url()); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); } } diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index b6c06e995a..3d4df2f980 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -6,6 +6,88 @@ toc_max_heading_level: 2 ## Version 1.42 +### Experimental JUnit integration + +Add new [`@UsePlaywright`](./junit.md) annotation to your test classes to start using Playwright +fixtures for [Page], [BrowserContext], [Browser], [APIRequestContext] and [Playwright] in the +test methods. + +```java +package org.example; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@UsePlaywright +public class TestExample { + void shouldNavigateToInstallationGuide(Page page) { + page.navigate("https://playwright.dev/java/"); + page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Docs")).click(); + assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Installation"))).isVisible(); + } + + @Test + void shouldCheckTheBox(Page page) { + page.setContent(""); + page.locator("input").check(); + assertEquals(true, page.evaluate("window['checkbox'].checked")); + } + + @Test + void shouldSearchWiki(Page page) { + page.navigate("https://www.wikipedia.org/"); + page.locator("input[name=\"search\"]").click(); + page.locator("input[name=\"search\"]").fill("playwright"); + page.locator("input[name=\"search\"]").press("Enter"); + assertThat(page).hasURL("https://en.wikipedia.org/wiki/Playwright"); + } +} +``` + +In the example above, all three test methods use the same [Browser]. Each test +uses its own [BrowserContext] and [Page]. + +**Custom options** + +Implement your own `OptionsFactory` to initialize the fixtures with custom configuration. + +```java +import com.microsoft.playwright.junit.Options; +import com.microsoft.playwright.junit.OptionsFactory; +import com.microsoft.playwright.junit.UsePlaywright; + +@UsePlaywright(MyTest.CustomOptions.class) +public class MyTest { + + public static class CustomOptions implements OptionsFactory { + @Override + public Options getOptions() { + return new Options() + .setHeadless(false) + .setContextOption(new Browser.NewContextOptions() + .setBaseURL("https://github.com")) + .setApiRequestOptions(new APIRequest.NewContextOptions() + .setBaseURL("https://playwright.dev")); + } + } + + @Test + public void testWithCustomOptions(Page page, APIRequestContext request) { + page.navigate("/"); + assertThat(page).hasURL(Pattern.compile("github")); + + APIResponse response = request.get("/"); + assertTrue(response.text().contains("Playwright")); + } +} +``` + +Learn more about the fixtures in our [JUnit guide](./junit.md). + ### New Locator Handler New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears. From 7840d3e6d977c618ab170f5b303bbb003516431c Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 12 Mar 2024 01:57:32 -0700 Subject: [PATCH 083/141] feat(webkit): roll to r1988 (#29883) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 0c3d876745..11e8384c90 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -33,7 +33,7 @@ }, { "name": "webkit", - "revision": "1987", + "revision": "1988", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From ab23e9139b8eb6687cd7e4157ab2d3bfeaa0abb7 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 12 Mar 2024 02:29:36 -0700 Subject: [PATCH 084/141] feat(chromium-tip-of-tree): roll to r1200 (#29884) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 11e8384c90..7e0749ead6 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1199", + "revision": "1200", "installByDefault": false, - "browserVersion": "124.0.6338.0" + "browserVersion": "124.0.6343.0" }, { "name": "firefox", From be325507cb29518c79bd7647785a3d754fbaa576 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 12 Mar 2024 05:47:56 -0700 Subject: [PATCH 085/141] feat(chromium-tip-of-tree): roll to r1201 (#29897) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 7e0749ead6..5f47c0a991 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1200", + "revision": "1201", "installByDefault": false, - "browserVersion": "124.0.6343.0" + "browserVersion": "124.0.6353.0" }, { "name": "firefox", From 38fc74db7c24398095632fa2e65e913c80df99f4 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 12 Mar 2024 17:20:39 +0100 Subject: [PATCH 086/141] fix: postDataJSON with application/x-www-form-urlencoded; charset=UTF-8 (#29889) Fixes https://github.com/microsoft/playwright/issues/29872 --- packages/playwright-core/src/client/network.ts | 2 +- tests/page/page-network-request.spec.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 75cda8d207..4bd7698016 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -141,7 +141,7 @@ export class Request extends ChannelOwner implements ap return null; const contentType = this.headers()['content-type']; - if (contentType === 'application/x-www-form-urlencoded') { + if (contentType.includes('application/x-www-form-urlencoded')) { const entries: Record = {}; const parsed = new URLSearchParams(postData); for (const [k, v] of parsed.entries()) diff --git a/tests/page/page-network-request.spec.ts b/tests/page/page-network-request.spec.ts index 37eb1a20dd..624e36abfd 100644 --- a/tests/page/page-network-request.spec.ts +++ b/tests/page/page-network-request.spec.ts @@ -290,6 +290,20 @@ it('should parse the data if content-type is application/x-www-form-urlencoded', expect(request.postDataJSON()).toEqual({ 'foo': 'bar', 'baz': '123' }); }); +it('should parse the data if content-type is application/x-www-form-urlencoded; charset=UTF-8', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29872' }); + await page.goto(server.EMPTY_PAGE); + const requestPromise = page.waitForRequest('**/post'); + await page.evaluate(() => fetch('./post', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: 'foo=bar&baz=123' + })); + expect((await requestPromise).postDataJSON()).toEqual({ 'foo': 'bar', 'baz': '123' }); +}); + it('should get |undefined| with postDataJSON() when there is no post data', async ({ page, server }) => { const response = await page.goto(server.EMPTY_PAGE); expect(response.request().postDataJSON()).toBe(null); From 276ef5c19bf66f8b0d470c179babab94df82484f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 12 Mar 2024 11:04:21 -0700 Subject: [PATCH 087/141] docs(auth): small note about UI mode (#29887) Fixes #29846. --------- Signed-off-by: Dmitry Gozman Co-authored-by: Max Schmitt --- docs/src/auth.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/auth.md b/docs/src/auth.md index 6600846773..ba7af6f4cd 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -113,6 +113,13 @@ test('test', async ({ page }) => { }); ``` +### Authenticating in UI mode +* langs: js + +UI mode will not run the `setup` project by default to improve testing speed. We recommend to authenticate by manually running the `auth.setup.ts` from time to time, whenever existing authentication expires. + +First [enable the `setup` project in the filters](./test-ui-mode#filtering-tests), then click the triangle button next to `auth.setup.ts` file, and then disable the `setup` project in the filters again. + ## Moderate: one account per parallel worker * langs: js @@ -457,6 +464,8 @@ test.describe(() => { }); ``` +See also about [authenticating in the UI mode](#authenticating-in-ui-mode). + ### Testing multiple roles together * langs: js From 78b8aed4bda94a8ad3b84b6fa0ac245321169af4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 12 Mar 2024 12:02:25 -0700 Subject: [PATCH 088/141] docs: mark addLocatorHandler as experimental (#29909) --- docs/src/api/class-page.md | 30 +++++++++++++---------- packages/playwright-core/types/types.d.ts | 16 ++++++------ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 4fe5421ab2..e462042daa 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3146,7 +3146,11 @@ return value resolves to `[]`. ## async method: Page.addLocatorHandler * since: v1.42 -When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making them tricky to handle in automated tests. +:::warning Experimental +This method is experimental and its behavior may change in the upcoming releases. +::: + +When testing a web page, sometimes unexpected overlays like a "Sign up" dialog appear and block actions you want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making them tricky to handle in automated tests. This method lets you set up a special function, called a handler, that activates when it detects that overlay is visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. @@ -3168,12 +3172,12 @@ Another example is a series of mouse actions, where [`method: Mouse.move`] is fo **Usage** -An example that closes a cookie consent dialog when it appears: +An example that closes a "Sign up to the newsletter" dialog when it appears: ```js // Setup the handler. -await page.addLocatorHandler(page.getByRole('button', { name: 'Accept all cookies' }), async () => { - await page.getByRole('button', { name: 'Reject all cookies' }).click(); +await page.addLocatorHandler(page.getByText('Sign up to the newsletter'), async () => { + await page.getByRole('button', { name: 'No thanks' }).click(); }); // Write the test as usual. @@ -3183,8 +3187,8 @@ await page.getByRole('button', { name: 'Start here' }).click(); ```java // Setup the handler. -page.addLocatorHandler(page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Accept all cookies")), () => { - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Reject all cookies")).click(); +page.addLocatorHandler(page.getByText("Sign up to the newsletter"), () => { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("No thanks")).click(); }); // Write the test as usual. @@ -3195,8 +3199,8 @@ page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click(); ```python sync # Setup the handler. def handler(): - page.get_by_role("button", name="Reject all cookies").click() -page.add_locator_handler(page.get_by_role("button", name="Accept all cookies"), handler) + page.get_by_role("button", name="No thanks").click() +page.add_locator_handler(page.get_by_text("Sign up to the newsletter"), handler) # Write the test as usual. page.goto("https://example.com") @@ -3206,8 +3210,8 @@ page.get_by_role("button", name="Start here").click() ```python async # Setup the handler. def handler(): - await page.get_by_role("button", name="Reject all cookies").click() -await page.add_locator_handler(page.get_by_role("button", name="Accept all cookies"), handler) + await page.get_by_role("button", name="No thanks").click() +await page.add_locator_handler(page.get_by_text("Sign up to the newsletter"), handler) # Write the test as usual. await page.goto("https://example.com") @@ -3216,8 +3220,8 @@ await page.get_by_role("button", name="Start here").click() ```csharp // Setup the handler. -await page.AddLocatorHandlerAsync(page.GetByRole(AriaRole.Button, new() { Name = "Accept all cookies" }), async () => { - await page.GetByRole(AriaRole.Button, new() { Name = "Reject all cookies" }).ClickAsync(); +await page.AddLocatorHandlerAsync(page.GetByText("Sign up to the newsletter"), async () => { + await page.GetByRole(AriaRole.Button, new() { Name = "No thanks" }).ClickAsync(); }); // Write the test as usual. @@ -3230,7 +3234,7 @@ An example that skips the "Confirm your security details" page when it is shown: ```js // Setup the handler. await page.addLocatorHandler(page.getByText('Confirm your security details'), async () => { - await page.getByRole('button', 'Remind me later').click(); + await page.getByRole('button', { name: 'Remind me later' }).click(); }); // Write the test as usual. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index f8a1dd584e..2521fb4858 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -1781,9 +1781,11 @@ export interface Page { prependListener(event: 'worker', listener: (worker: Worker) => void): this; /** - * When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you - * want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, - * making them tricky to handle in automated tests. + * **NOTE** This method is experimental and its behavior may change in the upcoming releases. + * + * When testing a web page, sometimes unexpected overlays like a "Sign up" dialog appear and block actions you want to + * automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making + * them tricky to handle in automated tests. * * This method lets you set up a special function, called a handler, that activates when it detects that overlay is * visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. @@ -1817,12 +1819,12 @@ export interface Page { * * **Usage** * - * An example that closes a cookie consent dialog when it appears: + * An example that closes a "Sign up to the newsletter" dialog when it appears: * * ```js * // Setup the handler. - * await page.addLocatorHandler(page.getByRole('button', { name: 'Accept all cookies' }), async () => { - * await page.getByRole('button', { name: 'Reject all cookies' }).click(); + * await page.addLocatorHandler(page.getByText('Sign up to the newsletter'), async () => { + * await page.getByRole('button', { name: 'No thanks' }).click(); * }); * * // Write the test as usual. @@ -1835,7 +1837,7 @@ export interface Page { * ```js * // Setup the handler. * await page.addLocatorHandler(page.getByText('Confirm your security details'), async () => { - * await page.getByRole('button', 'Remind me later').click(); + * await page.getByRole('button', { name: 'Remind me later' }).click(); * }); * * // Write the test as usual. From 166d2d4fde2089d40206319fa7f5a35d4e4712e0 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 12 Mar 2024 16:10:43 -0700 Subject: [PATCH 089/141] chore: separate results for repeated snapshot names (#29880) Reference #29719 --- .../src/matchers/toMatchSnapshot.ts | 2 +- tests/playwright-test/golden.spec.ts | 70 +++++++++++++++++++ tests/playwright-test/reporter-html.spec.ts | 4 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 317a0144d9..4e12d18fe6 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -140,7 +140,7 @@ class SnapshotHelper { snapshotNames.namedSnapshotIndex[joinedName] = (snapshotNames.namedSnapshotIndex[joinedName] || 0) + 1; const index = snapshotNames.namedSnapshotIndex[joinedName]; if (index > 1) - this.snapshotName = `${joinedName}-${index - 1}`; + this.snapshotName = addSuffixToFilePath(joinedName, `-${index - 1}`); else this.snapshotName = joinedName; } diff --git a/tests/playwright-test/golden.spec.ts b/tests/playwright-test/golden.spec.ts index 39efa3c0b8..e768e1cf60 100644 --- a/tests/playwright-test/golden.spec.ts +++ b/tests/playwright-test/golden.spec.ts @@ -90,6 +90,76 @@ test('should generate default name', async ({ runInlineTest }, testInfo) => { expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-5.dat'))).toBe(true); }); +test('should generate separate actual results for repeating names', async ({ runInlineTest }, testInfo) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29719' }); + const result = await runInlineTest({ + ...files, + 'a.spec.js-snapshots/foo.txt': `b`, + 'a.spec.js-snapshots/bar/baz.txt': `c`, + 'a.spec.js': ` + const { test, expect } = require('./helper'); + test.afterEach(async ({}, testInfo) => { + console.log('## ' + JSON.stringify(testInfo.attachments)); + }); + test('is a test', ({}) => { + expect.soft('a').toMatchSnapshot('foo.txt'); + expect.soft('a').toMatchSnapshot('foo.txt'); + expect.soft('b').toMatchSnapshot(['bar', 'baz.txt']); + expect.soft('b').toMatchSnapshot(['bar', 'baz.txt']); + }); + ` + }); + + const outputText = result.output; + const attachments = outputText.split('\n').filter(l => l.startsWith('## ')).map(l => l.substring(3)).map(l => JSON.parse(l))[0]; + for (const attachment of attachments) { + attachment.path = attachment.path.replace(/\\/g, '/').replace(/.*test-results\//, ''); + attachment.name = attachment.name.replace(/\\/g, '/'); + } + expect(attachments).toEqual([ + { + 'name': 'foo-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/foo.txt' + }, + { + 'name': 'foo-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/foo-actual.txt' + }, + { + 'name': 'foo-1-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/foo.txt' + }, + { + 'name': 'foo-1-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/foo-1-actual.txt' + }, + { + 'name': 'bar/baz-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/bar/baz.txt' + }, + { + 'name': 'bar/baz-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/bar-baz-actual.txt' + }, + { + 'name': 'bar/baz-1-expected.txt', + 'contentType': 'text/plain', + 'path': 'golden-should-generate-separate-actual-results-for-repeating-names-playwright-test/a.spec.js-snapshots/bar/baz.txt' + }, + { + 'name': 'bar/baz-1-actual.txt', + 'contentType': 'text/plain', + 'path': 'a-is-a-test/bar-baz-1-actual.txt' + } + ]); +}); + test('should compile with different option combinations', async ({ runTSC }) => { const result = await runTSC({ 'a.spec.ts': ` diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index a819f25be8..ad39504c64 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -261,8 +261,8 @@ for (const useIntermediateMergeReport of [false, true] as const) { await expect(page.locator('data-testid=test-result-image-mismatch')).toHaveCount(3); await expect(page.locator('text=Image mismatch:')).toHaveText([ 'Image mismatch: expected.png', - 'Image mismatch: expected.png-1', - 'Image mismatch: expected.png-2', + 'Image mismatch: expected-1.png', + 'Image mismatch: expected-2.png', ]); }); From 2ce421b27a70992f1f631437fb481950f44b561e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 12 Mar 2024 16:32:58 -0700 Subject: [PATCH 090/141] fix(trace viewer): synchronize monotonic times between context entries (#29908) When displaying remote context trace and local test runner trace together, we should not compare monotonic time between the two. This change reuses existing logic for merging actions timing to also update context boundaries and events by the same time delta. Fixes #29767. --- packages/trace-viewer/src/ui/modelUtil.ts | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 1b6b7cdff4..d1f1adbb2f 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -89,11 +89,12 @@ export class MultiTraceModel { this.platform = primaryContext?.platform || ''; this.title = primaryContext?.title || ''; this.options = primaryContext?.options || {}; + // Next call updates all timestamps for all events in non-primary contexts, so it must be done first. + this.actions = mergeActionsAndUpdateTiming(contexts); + this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE); this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE); this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE); - this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); - this.actions = mergeActions(contexts); this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events)); this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio)); this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); @@ -158,7 +159,7 @@ function indexModel(context: ContextEntry) { (event as any)[contextSymbol] = context; } -function mergeActions(contexts: ContextEntry[]) { +function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) { const map = new Map(); // Protocol call aka isPrimary contexts have startTime/endTime as server-side times. @@ -176,12 +177,16 @@ function mergeActions(contexts: ContextEntry[]) { } const nonPrimaryIdToPrimaryId = new Map(); + const nonPrimaryTimeDelta = new Map(); for (const context of nonPrimaryContexts) { for (const action of context.actions) { if (offset) { const duration = action.endTime - action.startTime; - if (action.startTime) - action.startTime = action.wallTime + offset; + if (action.startTime) { + const newStartTime = action.wallTime + offset; + nonPrimaryTimeDelta.set(context, newStartTime - action.startTime); + action.startTime = newStartTime; + } if (action.endTime) action.endTime = action.startTime + duration; } @@ -204,6 +209,17 @@ function mergeActions(contexts: ContextEntry[]) { } } + for (const [context, timeDelta] of nonPrimaryTimeDelta) { + context.startTime += timeDelta; + context.endTime += timeDelta; + for (const event of context.events) + event.time += timeDelta; + for (const page of context.pages) { + for (const frame of page.screencastFrames) + frame.timestamp += timeDelta; + } + } + const result = [...map.values()]; result.sort((a1, a2) => { if (a2.parentId === a1.callId) From 349b25e61a4814c7a7fc6f9d3bef4c3fdf7d505c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 12 Mar 2024 19:20:35 -0700 Subject: [PATCH 091/141] fix(storageState): try to collect storage state on existing pages first (#29915) This helps in a case where navigating to an origin fails for some reason, for example because a registered service worker loads some content into the supposedly blank page. Fixes #29402. --- .../src/server/browserContext.ts | 24 ++++++- packages/playwright-core/src/server/frames.ts | 6 ++ packages/playwright-core/src/server/page.ts | 11 ++-- .../browsercontext-storage-state.spec.ts | 65 +++++++++++++++++-- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 51add38e0e..2a5dfe765f 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -484,14 +484,34 @@ export abstract class BrowserContext extends SdkObject { cookies: await this.cookies(), origins: [] }; - if (this._origins.size) { + const originsToSave = new Set(this._origins); + + // First try collecting storage stage from existing pages. + for (const page of this.pages()) { + const origin = page.mainFrame().origin(); + if (!origin || !originsToSave.has(origin)) + continue; + try { + const storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`({ + localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })), + })`, false, 'utility'); + if (storage.localStorage.length) + result.origins.push({ origin, localStorage: storage.localStorage } as channels.OriginStorage); + originsToSave.delete(origin); + } catch { + // When failed on the live page, we'll retry on the blank page below. + } + } + + // If there are still origins to save, create a blank page to iterate over origins. + if (originsToSave.size) { const internalMetadata = serverSideCallMetadata(); const page = await this.newPage(internalMetadata); await page._setServerRequestInterceptor(handler => { handler.fulfill({ body: '', requestUrl: handler.request().url() }).catch(() => {}); return true; }); - for (const origin of this._origins) { + for (const origin of originsToSave) { const originStorage: channels.OriginStorage = { origin, localStorage: [] }; const frame = page.mainFrame(); await frame.goto(internalMetadata, origin); diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index dab9a1ebcc..6dc5563d96 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -914,6 +914,12 @@ export class Frame extends SdkObject { return this._url; } + origin(): string | undefined { + if (!this._url.startsWith('http')) + return; + return network.parsedURL(this._url)?.origin; + } + parentFrame(): Frame | null { return this._parentFrame; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 3a666fa6a6..d449de1f79 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -19,7 +19,7 @@ import type * as dom from './dom'; import * as frames from './frames'; import * as input from './input'; import * as js from './javascript'; -import * as network from './network'; +import type * as network from './network'; import type * as channels from '@protocol/channels'; import type { ScreenshotOptions } from './screenshotter'; import { Screenshotter, validateScreenshotOptions } from './screenshotter'; @@ -706,12 +706,9 @@ export class Page extends SdkObject { frameNavigatedToNewDocument(frame: frames.Frame) { this.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame); - const url = frame.url(); - if (!url.startsWith('http')) - return; - const purl = network.parsedURL(url); - if (purl) - this._browserContext.addVisitedOrigin(purl.origin); + const origin = frame.origin(); + if (origin) + this._browserContext.addVisitedOrigin(origin); } allBindings() { diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 2e4fa6b050..7bd2581c9a 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -34,17 +34,17 @@ it('should capture local storage', async ({ contextFactory }) => { }); const { origins } = await context.storageState(); expect(origins).toEqual([{ - origin: 'https://www.example.com', - localStorage: [{ - name: 'name1', - value: 'value1' - }], - }, { origin: 'https://www.domain.com', localStorage: [{ name: 'name2', value: 'value2' }], + }, { + origin: 'https://www.example.com', + localStorage: [{ + name: 'name1', + value: 'value1' + }], }]); }); @@ -222,3 +222,56 @@ it('should serialize storageState with lone surrogates', async ({ page, context, const storageState = await context.storageState(); expect(storageState.origins[0].localStorage[0].value).toBe(String.fromCharCode(55934)); }); + +it('should work when service worker is intefering', async ({ page, context, server, isAndroid, isElectron }) => { + it.skip(isAndroid); + it.skip(isElectron); + + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(` + + `); + }); + + server.setRoute('/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }); + res.end(` + const kHtmlPage = \` + + \`; + + console.log('from sw 1'); + self.addEventListener('fetch', event => { + console.log('fetching ' + event.request.url); + const blob = new Blob([kHtmlPage], { type: 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + console.log('from sw 2'); + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX); + await page.evaluate(() => window['activationPromise']); + + const storageState = await context.storageState(); + expect(storageState.origins[0].localStorage[0]).toEqual({ name: 'foo', value: 'bar' }); +}); From 914208c567addd4ba985a5decd37b05e15128cd0 Mon Sep 17 00:00:00 2001 From: Karl Horky Date: Wed, 13 Mar 2024 12:22:40 +0100 Subject: [PATCH 092/141] chore: fix typo in property name (#29907) --- packages/playwright-core/src/server/firefox/ffPage.ts | 2 +- packages/playwright-core/src/server/frames.ts | 2 +- packages/playwright-core/src/server/page.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 63be31ba29..49435823c2 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -40,7 +40,7 @@ import { TargetClosedError } from '../errors'; export const UTILITY_WORLD_NAME = '__playwright_utility_world__'; export class FFPage implements PageDelegate { - readonly cspErrorsAsynchronousForInlineScipts = true; + readonly cspErrorsAsynchronousForInlineScripts = true; readonly rawMouse: RawMouseImpl; readonly rawKeyboard: RawKeyboardImpl; readonly rawTouchscreen: RawTouchscreenImpl; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 6dc5563d96..787fae44fe 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -948,7 +948,7 @@ export class Frame extends SdkObject { const result = (await context.evaluateHandle(addScriptContent, { content: content!, type })).asElement()!; // Another round trip to the browser to ensure that we receive CSP error messages // (if any) logged asynchronously in a separate task on the content main thread. - if (this._page._delegate.cspErrorsAsynchronousForInlineScipts) + if (this._page._delegate.cspErrorsAsynchronousForInlineScripts) await context.evaluate(() => true); return result; }); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index d449de1f79..2c3fd8f6dd 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -95,7 +95,7 @@ export interface PageDelegate { // Work around Chrome's non-associated input and protocol. inputActionEpilogue(): Promise; // Work around for asynchronously dispatched CSP errors in Firefox. - readonly cspErrorsAsynchronousForInlineScipts?: boolean; + readonly cspErrorsAsynchronousForInlineScripts?: boolean; // Work around for mouse position in Firefox. resetForReuse(): Promise; // WebKit hack. From 1114c42e44d0d2f32fcbab1313dd41f330dfde9f Mon Sep 17 00:00:00 2001 From: Debbie O'Brien Date: Thu, 14 Mar 2024 15:45:26 +0100 Subject: [PATCH 093/141] docs: add release video to release notes (#29939) --- docs/src/release-notes-js.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 6440e91faa..423dcc9b60 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -8,6 +8,11 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ## Version 1.42 + + ### New APIs - New method [`method: Page.addLocatorHandler`] registers a callback that will be invoked when specified element becomes visible and may block Playwright actions. The callback can get rid of the overlay. Here is an example that closes a cookie dialog when it appears: From b158e4ef96785938e3b3e7d5e750ce557beb6297 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 14 Mar 2024 16:48:34 +0100 Subject: [PATCH 094/141] fix: postDataJSON without Content-Type header (#29918) Regressed after https://github.com/microsoft/playwright/commit/38fc74db7c24398095632fa2e65e913c80df99f4. Test coverage: Some tests were failing on the flakiness dashboard --- packages/playwright-core/src/client/network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 4bd7698016..f58048655e 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -141,7 +141,7 @@ export class Request extends ChannelOwner implements ap return null; const contentType = this.headers()['content-type']; - if (contentType.includes('application/x-www-form-urlencoded')) { + if (contentType?.includes('application/x-www-form-urlencoded')) { const entries: Record = {}; const parsed = new URLSearchParams(postData); for (const [k, v] of parsed.entries()) From 93dc89fa1f53b4b6e90eae8b99967da8662fbcc5 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 14 Mar 2024 19:26:42 +0100 Subject: [PATCH 095/141] test: add file upload test case with popup handling (#29937) https://github.com/microsoft/playwright/issues/29923 --------- Signed-off-by: Max Schmitt --- tests/page/page-set-input-files.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index 966459a2c9..b43239596c 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -37,6 +37,23 @@ it('should upload the file', async ({ page, server, asset }) => { }, input)).toBe('contents of the file'); }); +it('should upload a file after popup', async ({ page, server, asset, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29923' }); + it.fixme(browserName === 'firefox'); + await page.goto(server.PREFIX + '/input/fileupload.html'); + { + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(() => window['__popup'] = window.open('about:blank')), + ]); + await popup.close(); + } + const filePath = path.relative(process.cwd(), asset('file-to-upload.txt')); + const input = await page.$('input'); + await input.setInputFiles(filePath); + expect(await page.evaluate(e => e.files[0].name, input)).toBe('file-to-upload.txt'); +}); + it('should upload large file', async ({ page, server, browserName, isMac, isAndroid, isWebView2, mode }, testInfo) => { it.skip(browserName === 'webkit' && isMac && parseInt(os.release(), 10) < 20, 'WebKit for macOS 10.15 is frozen and does not have corresponding protocol features.'); it.skip(isAndroid); From 7cbef691ae1bbe5b3ae6f8b8b24b14e5ac77870a Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 14 Mar 2024 20:27:33 +0100 Subject: [PATCH 096/141] fix: throw error if setInputFile does not exist (#29944) Fixes https://github.com/microsoft/playwright/issues/29941 --- packages/playwright-core/src/server/dom.ts | 9 +++++++-- tests/page/page-set-input-files.spec.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index f2be757de4..b413018b2c 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import fs from 'fs'; import type * as channels from '@protocol/channels'; import * as injectedScriptSource from '../generated/injectedScriptSource'; import { isSessionClosedError } from './protocolError'; @@ -642,10 +643,14 @@ export class ElementHandle extends js.JSHandle { await progress.beforeInputAction(this); await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { progress.throwIfAborted(); // Avoid action that has side-effects. - if (localPaths) + if (localPaths) { + await Promise.all(localPaths.map(localPath => ( + fs.promises.access(localPath, fs.constants.F_OK) + ))); await this._page._delegate.setInputFilePaths(retargeted, localPaths); - else + } else { await this._page._delegate.setInputFiles(retargeted, filePayloads!); + } }); return 'done'; } diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index b43239596c..eb7aacaa97 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -105,6 +105,14 @@ it('should upload large file', async ({ page, server, browserName, isMac, isAndr await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink)); }); +it('should throw an error if the file does not exist', async ({ page, server, asset }) => { + await page.goto(server.PREFIX + '/input/fileupload.html'); + const input = await page.$('input'); + const error = await input.setInputFiles('i actually do not exist.txt').catch(e => e); + expect(error.message).toContain('ENOENT: no such file or directory'); + expect(error.message).toContain('i actually do not exist.txt'); +}); + it('should upload multiple large files', async ({ page, server, browserName, isMac, isAndroid, isWebView2, mode }, testInfo) => { it.skip(browserName === 'webkit' && isMac && parseInt(os.release(), 10) < 20, 'WebKit for macOS 10.15 is frozen and does not have corresponding protocol features.'); it.skip(isAndroid); From 23bfeec5c726bce035bd0f2242cbe013c9515aa4 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:15:08 -0700 Subject: [PATCH 097/141] feat(webkit): roll to r1989 (#29942) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5f47c0a991..56ccde72d7 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -33,7 +33,7 @@ }, { "name": "webkit", - "revision": "1988", + "revision": "1989", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 94348bb3c5be35d002e3cf337119adddfede835f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 14 Mar 2024 15:44:35 -0700 Subject: [PATCH 098/141] chore: align test tree with vscode (#29864) --- .../playwright/src/isomorphic/testTree.ts | 110 ++++++++++++------ packages/playwright/types/testReporter.d.ts | 2 +- .../trace-viewer/src/ui/teleSuiteUpdater.ts | 9 +- packages/trace-viewer/src/ui/uiModeView.tsx | 22 ++-- .../overrides-testReporter.d.ts | 2 +- 5 files changed, 94 insertions(+), 51 deletions(-) diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts index 3c523670f5..8f11dea0d7 100644 --- a/packages/playwright/src/isomorphic/testTree.ts +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -39,28 +39,31 @@ export type TestCaseItem = TreeItemBase & { kind: 'case', tests: reporterTypes.TestCase[]; children: TestItem[]; + test: reporterTypes.TestCase | undefined; + project: reporterTypes.FullProject | undefined; }; export type TestItem = TreeItemBase & { kind: 'test', test: reporterTypes.TestCase; - project: string; + project: reporterTypes.FullProject; }; export type TreeItem = GroupItem | TestCaseItem | TestItem; export class TestTree { rootItem: GroupItem; - readonly treeItemMap = new Map(); - readonly visibleTestIds = new Set(); - readonly fileNames = new Set(); + private _treeItemById = new Map(); + private _treeItemByTestId = new Map(); + readonly pathSeparator: string; - constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map) { - const filterProjects = [...projectFilters.values()].some(Boolean); + constructor(rootFolder: string, rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map | undefined, pathSeparator: string) { + const filterProjects = projectFilters && [...projectFilters.values()].some(Boolean); + this.pathSeparator = pathSeparator; this.rootItem = { kind: 'group', subKind: 'folder', - id: 'root', + id: rootFolder, title: '', location: { file: '', line: 0, column: 0 }, duration: 0, @@ -69,8 +72,9 @@ export class TestTree { status: 'none', hasLoadErrors: false, }; + this._treeItemById.set(rootFolder, this.rootItem); - const visitSuite = (projectName: string, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => { + const visitSuite = (project: reporterTypes.FullProject, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => { for (const suite of parentSuite.suites) { const title = suite.title || ''; let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined; @@ -87,9 +91,9 @@ export class TestTree { status: 'none', hasLoadErrors: false, }; - parentGroup.children.push(group); + this._addChild(parentGroup, group); } - visitSuite(projectName, suite, group); + visitSuite(project, suite, group); } for (const test of parentSuite.tests) { @@ -106,8 +110,10 @@ export class TestTree { location: test.location, duration: 0, status: 'none', + project: undefined, + test: undefined, }; - parentGroup.children.push(testCaseItem); + this._addChild(parentGroup, testCaseItem); } const result = test.results[0]; @@ -126,40 +132,47 @@ export class TestTree { status = 'passed'; testCaseItem.tests.push(test); - testCaseItem.children.push({ + const testItem: TestItem = { kind: 'test', id: test.id, - title: projectName, + title: project.name, location: test.location!, test, parent: testCaseItem, children: [], status, duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, - project: projectName, - }); + project, + }; + this._addChild(testCaseItem, testItem); + this._treeItemByTestId.set(test.id, testItem); testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0); } }; - const fileMap = new Map(); for (const projectSuite of rootSuite?.suites || []) { if (filterProjects && !projectFilters.get(projectSuite.title)) continue; for (const fileSuite of projectSuite.suites) { - const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true, fileMap); - visitSuite(projectSuite.title, fileSuite, fileItem); + const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true); + visitSuite(projectSuite.project()!, fileSuite, fileItem); } } for (const loadError of loadErrors) { if (!loadError.location) continue; - const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true, fileMap); + const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true); fileItem.hasLoadErrors = true; } } + private _addChild(parent: TreeItem, child: TreeItem) { + parent.children.push(child); + child.parent = parent; + this._treeItemById.set(child.id, child); + } + filterTree(filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { const tokens = filterText.trim().toLowerCase().split(' '); const filtersStatuses = [...statusFilters.values()].some(Boolean); @@ -192,14 +205,14 @@ export class TestTree { visit(this.rootItem); } - private _fileItem(filePath: string[], isFile: boolean, fileItems: Map): GroupItem { + private _fileItem(filePath: string[], isFile: boolean): GroupItem { if (filePath.length === 0) return this.rootItem; - const fileName = filePath.join(pathSeparator); - const existingFileItem = fileItems.get(fileName); + const fileName = filePath.join(this.pathSeparator); + const existingFileItem = this._treeItemById.get(fileName); if (existingFileItem) - return existingFileItem; - const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false, fileItems); + return existingFileItem as GroupItem; + const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false); const fileItem: GroupItem = { kind: 'group', subKind: isFile ? 'file' : 'folder', @@ -212,8 +225,7 @@ export class TestTree { status: 'none', hasLoadErrors: false, }; - parentFileItem.children.push(fileItem); - fileItems.set(fileName, fileItem); + this._addChild(parentFileItem, fileItem); return fileItem; } @@ -221,12 +233,16 @@ export class TestTree { sortAndPropagateStatus(this.rootItem); } - hideOnlyTests() { + flattenForSingleProject() { const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case' && treeItem.children.length === 1) + if (treeItem.kind === 'case' && treeItem.children.length === 1) { + treeItem.project = treeItem.children[0].project; + treeItem.test = treeItem.children[0].test; treeItem.children = []; - else + this._treeItemByTestId.set(treeItem.test.id, treeItem); + } else { treeItem.children.forEach(visit); + } }; visit(this.rootItem); } @@ -239,16 +255,41 @@ export class TestTree { this.rootItem = shortRoot; } - indexTree() { + testIds(): Set { + const result = new Set(); const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'group' && treeItem.location.file) - this.fileNames.add(treeItem.location.file); if (treeItem.kind === 'case') - treeItem.tests.forEach(t => this.visibleTestIds.add(t.id)); + treeItem.tests.forEach(t => result.add(t.id)); treeItem.children.forEach(visit); - this.treeItemMap.set(treeItem.id, treeItem); }; visit(this.rootItem); + return result; + } + + fileNames(): string[] { + const result = new Set(); + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'group' && treeItem.subKind === 'file') + result.add(treeItem.id); + else + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + return [...result]; + } + + flatTreeItems(): TreeItem[] { + const result: TreeItem[] = []; + const visit = (treeItem: TreeItem) => { + result.push(treeItem); + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + return result; + } + + treeItemById(id: string): TreeItem | undefined { + return this._treeItemById.get(id); } collectTestIds(treeItem?: TreeItem): Set { @@ -312,5 +353,4 @@ export function sortAndPropagateStatus(treeItem: TreeItem) { treeItem.status = 'passed'; } -export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; export const statusEx = Symbol('statusEx'); diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index 728f5299ef..ed6f62e71d 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -16,7 +16,7 @@ */ import type { FullConfig, FullProject, TestStatus, Metadata } from './test'; -export type { FullConfig, TestStatus } from './test'; +export type { FullConfig, TestStatus, FullProject } from './test'; /** * `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy: diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts index 50abfbf276..82e7ab2913 100644 --- a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -15,7 +15,7 @@ */ import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver'; -import { pathSeparator, statusEx } from '@testIsomorphic/testTree'; +import { statusEx } from '@testIsomorphic/testTree'; import type { ReporterV2 } from 'playwright/src/reporters/reporterV2'; import type * as reporterTypes from 'playwright/types/testReporter'; @@ -28,7 +28,8 @@ export type Progress = { export type TeleSuiteUpdaterOptions = { onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void, - onError?: (error: reporterTypes.TestError) => void + onError?: (error: reporterTypes.TestError) => void; + pathSeparator: string; }; export class TeleSuiteUpdater { @@ -51,7 +52,7 @@ export class TeleSuiteUpdater { this._receiver = new TeleReporterReceiver(this._createReporter(), { mergeProjects: true, mergeTestCases: true, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + resolvePath: (rootDir, relativePath) => rootDir + options.pathSeparator + relativePath, clearPreviousResultsWhenTestBegins: true, }); this._options = options; @@ -75,7 +76,7 @@ export class TeleSuiteUpdater { }, { mergeProjects: true, mergeTestCases: false, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + resolvePath: (rootDir, relativePath) => rootDir + this._options.pathSeparator + relativePath, }); }, diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index c7406fb49f..0d7bc46bad 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -379,13 +379,12 @@ const TestList: React.FC<{ // Build the test tree. const { testTree } = React.useMemo(() => { - const testTree = new TestTree(testModel.rootSuite, testModel.loadErrors, projectFilters); + const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); testTree.filterTree(filterText, statusFilters, runningState?.testIds); testTree.sortAndPropagateStatus(); testTree.shortenRoot(); - testTree.hideOnlyTests(); - testTree.indexTree(); - setVisibleTestIds(testTree.visibleTestIds); + testTree.flattenForSingleProject(); + setVisibleTestIds(testTree.testIds()); return { testTree }; }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); @@ -394,8 +393,8 @@ const TestList: React.FC<{ // If collapse was requested, clear the expanded items and return w/o selected item. if (collapseAllCount !== requestedCollapseAllCount) { treeState.expandedItems.clear(); - for (const item of testTree.treeItemMap.keys()) - treeState.expandedItems.set(item, false); + for (const item of testTree.flatTreeItems()) + treeState.expandedItems.set(item.id, false); setCollapseAllCount(requestedCollapseAllCount); setSelectedTreeItemId(undefined); setTreeState({ ...treeState }); @@ -424,7 +423,7 @@ const TestList: React.FC<{ // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { - const selectedTreeItem = selectedTreeItemId ? testTree.treeItemMap.get(selectedTreeItemId) : undefined; + const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; let testFile: SourceLocation | undefined; if (selectedTreeItem) { testFile = { @@ -450,11 +449,11 @@ const TestList: React.FC<{ if (isLoading) return; if (watchAll) { - sendMessageNoReply('watch', { fileNames: [...testTree.fileNames] }); + sendMessageNoReply('watch', { fileNames: testTree.fileNames() }); } else { const fileNames = new Set(); for (const itemId of watchedTreeIds.value) { - const treeItem = testTree.treeItemMap.get(itemId); + const treeItem = testTree.treeItemById(itemId); const fileName = treeItem?.location.file; if (fileName) fileNames.add(fileName); @@ -482,7 +481,7 @@ const TestList: React.FC<{ visit(testTree.rootItem); } else { for (const treeId of watchedTreeIds.value) { - const treeItem = testTree.treeItemMap.get(treeId); + const treeItem = testTree.treeItemById(treeId); const fileName = treeItem?.location.file; if (fileName && set.has(fileName)) testIds.push(...testTree.collectTestIds(treeItem)); @@ -624,6 +623,7 @@ const refreshRootSuite = (): Promise => { onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); }, + pathSeparator, }); return sendMessage('list', {}); }; @@ -681,3 +681,5 @@ async function loadSingleTraceFile(url: string): Promise { const contextEntries = await response.json() as ContextEntry[]; return new MultiTraceModel(contextEntries); } + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index db8ae7c6de..9aeb9c9d31 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -15,7 +15,7 @@ */ import type { FullConfig, FullProject, TestStatus, Metadata } from './test'; -export type { FullConfig, TestStatus } from './test'; +export type { FullConfig, TestStatus, FullProject } from './test'; export interface Suite { project(): FullProject | undefined; From 3e78426accf61fd8bd4c3294e71e58f66b42fab1 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 15 Mar 2024 16:25:36 +0100 Subject: [PATCH 099/141] fix(trace-viewer): report upload error as aria-role=alert (#29956) Turns out NVDA and Narrator work differently. This is a follow-up to https://github.com/microsoft/playwright/pull/29838 which was not working in NVDA before - looks like a NVDA bug. Using alert instead. Relates https://github.com/microsoft/playwright/issues/29095#issuecomment-1999332067 --- packages/trace-viewer/src/ui/workbenchLoader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 71e0165332..bc87a514db 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -162,7 +162,7 @@ export const WorkbenchLoader: React.FunctionComponent<{ } {showFileUploadDropArea &&
-
{processingErrorMessage}
+
{processingErrorMessage}
Drop Playwright Trace to load
or
; @@ -390,7 +391,8 @@ const TestList: React.FC<{ setVisibleTestIds: (testIds: Set) => void, onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, requestedCollapseAllCount: number, -}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => { + testServerConnection: TestServerConnection | undefined, +}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount, testServerConnection }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); @@ -464,10 +466,10 @@ const TestList: React.FC<{ // Update watch all. React.useEffect(() => { - if (isLoading) + if (isLoading || !testServerConnection) return; if (watchAll) { - sendMessageNoReply('watch', { fileNames: testTree.fileNames() }); + testServerConnection.watch({ fileNames: testTree.fileNames() }).catch(() => {}); } else { const fileNames = new Set(); for (const itemId of watchedTreeIds.value) { @@ -476,9 +478,9 @@ const TestList: React.FC<{ if (fileName) fileNames.add(fileName); } - sendMessageNoReply('watch', { fileNames: [...fileNames] }); + testServerConnection.watch({ fileNames: [...fileNames] }).catch(() => {}); } - }, [isLoading, testTree, watchAll, watchedTreeIds]); + }, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); @@ -520,7 +522,7 @@ const TestList: React.FC<{ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} runTreeItem(treeItem)} disabled={!!runningState}> - sendMessageNoReply('open', { location: testTree.locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + testServerConnection?.open({ location: treeItem.location }).catch(() => {})} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) watchedTreeIds.value.delete(treeItem.id); @@ -633,7 +635,7 @@ const throttleUpdateRootSuite = (config: reporterTypes.FullConfig, rootSuite: re throttleTimer = setTimeout(throttledAction, 250); }; -const refreshRootSuite = (): Promise => { +const refreshRootSuite = async (testServerConnection: TestServerConnection): Promise => { teleSuiteUpdater = new TeleSuiteUpdater({ onUpdate: (source, immediate) => { throttleUpdateRootSuite(source.config!, source.rootSuite || new TeleSuite('', 'root'), source.loadErrors, source.progress, immediate); @@ -643,45 +645,39 @@ const refreshRootSuite = (): Promise => { }, pathSeparator, }); - return sendMessage('listTests', {}); + return testServerConnection.listTests({}); }; -const sendMessageNoReply = (method: string, params?: any) => { - if ((window as any)._overrideProtocolForTest) { - (window as any)._overrideProtocolForTest({ method, params }).catch(() => {}); - return; - } - sendMessage(method, params).catch((e: Error) => { - // eslint-disable-next-line no-console - console.error(e); +const wireConnectionListeners = (testServerConnection: TestServerConnection) => { + testServerConnection.onListChanged(() => { + testServerConnection.listTests({}).catch(() => {}); }); -}; -const dispatchEvent = (method: string, params?: any) => { - if (method === 'listChanged') { - sendMessage('listTests', {}).catch(() => {}); - return; - } + testServerConnection.onTestFilesChanged(testFiles => { + runWatchedTests(testFiles); + }); - if (method === 'testFilesChanged') { - runWatchedTests(params.testFileNames); - return; - } - - if (method === 'stdio') { + testServerConnection.onStdio(params => { if (params.buffer) { const data = atob(params.buffer); xtermDataSource.write(data); } else { - xtermDataSource.write(params.text); + xtermDataSource.write(params.text!); } - return; - } + }); - if (method === 'listReport') + testServerConnection.onListReport(params => { teleSuiteUpdater?.dispatch('list', params); - if (method === 'testReport') + }); + + testServerConnection.onTestReport(params => { teleSuiteUpdater?.dispatch('test', params); + }); + + xtermDataSource.resize = (cols, rows) => { + xtermSize = { cols, rows }; + testServerConnection.resizeTerminal({ cols, rows }).catch(() => {}); + }; }; const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { diff --git a/tests/playwright-test/ui-mode-test-setup.spec.ts b/tests/playwright-test/ui-mode-test-setup.spec.ts index 702fbf7640..4def43d078 100644 --- a/tests/playwright-test/ui-mode-test-setup.spec.ts +++ b/tests/playwright-test/ui-mode-test-setup.spec.ts @@ -40,9 +40,12 @@ test('should run global setup and teardown', async ({ runUITest }) => { }); await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + + await page.getByTitle('Toggle output').click(); + await expect(page.getByTestId('output')).toContainText('from-global-setup'); await page.close(); + await expect.poll(() => testProcess.outputLines()).toEqual([ - 'from-global-setup', 'from-global-teardown', ]); }); @@ -70,9 +73,11 @@ test('should teardown on sigint', async ({ runUITest }) => { }); await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByTitle('Toggle output').click(); + await expect(page.getByTestId('output')).toContainText('from-global-setup'); + testProcess.process.kill('SIGINT'); await expect.poll(() => testProcess.outputLines()).toEqual([ - 'from-global-setup', 'from-global-teardown', ]); }); diff --git a/tests/playwright-test/ui-mode-test-update.spec.ts b/tests/playwright-test/ui-mode-test-update.spec.ts index 67102b55c0..b55633c12f 100644 --- a/tests/playwright-test/ui-mode-test-update.spec.ts +++ b/tests/playwright-test/ui-mode-test-update.spec.ts @@ -183,7 +183,7 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile `); const messages: any = []; - await page.exposeBinding('_overrideProtocolForTest', (_, data) => messages.push(data)); + await page.exposeBinding('_sniffProtocolForTest', (_, data) => messages.push(data)); const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' }); await passesItemLocator.hover(); @@ -192,7 +192,11 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile expect(messages).toEqual([{ method: 'open', params: { - location: expect.stringContaining('a.test.ts:3'), + location: { + file: expect.stringContaining('a.test.ts'), + line: 3, + column: 11, + } }, }]); @@ -218,7 +222,11 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile expect(messages).toEqual([{ method: 'open', params: { - location: expect.stringContaining('a.test.ts:5'), + location: { + file: expect.stringContaining('a.test.ts'), + line: 5, + column: 11, + } }, }]); From 1bb463163b4efef507a4b8d8feeb48e49e4a756e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 19 Mar 2024 14:01:04 -0700 Subject: [PATCH 119/141] feat(codegen): add button to generate toHaveScreenshot statement (#29996) Fixes #29250. --- .../src/server/injected/highlight.css | 6 ++++ .../src/server/injected/recorder/recorder.ts | 25 ++++++++++++++-- .../src/server/recorder/csharp.ts | 2 ++ .../src/server/recorder/java.ts | 2 ++ .../src/server/recorder/javascript.ts | 2 ++ .../src/server/recorder/python.ts | 30 ++++++++++--------- .../src/server/recorder/recorderActions.ts | 7 ++++- tests/library/inspector/cli-codegen-3.spec.ts | 14 +++++++++ 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 9ee0370720..4efa63de0a 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -231,6 +231,12 @@ x-pw-tool-item.value > x-div { mask-image: url("data:image/svg+xml;utf8,"); } +x-pw-tool-item.screenshot > x-div { + /* codicon: device-camera */ + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); +} + x-pw-tool-item.accept > x-div { -webkit-mask-image: url("data:image/svg+xml;utf8,"); mask-image: url("data:image/svg+xml;utf8,"); diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 2074ffa67f..e85f91c44c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -761,6 +761,7 @@ class Overlay { private _assertVisibilityToggle: HTMLElement; private _assertTextToggle: HTMLElement; private _assertValuesToggle: HTMLElement; + private _assertScreenshotButton: HTMLElement; private _offsetX = 0; private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined; private _measure: { width: number, height: number } = { width: 0, height: 0 }; @@ -807,6 +808,12 @@ class Overlay { this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div')); toolsListElement.appendChild(this._assertValuesToggle); + this._assertScreenshotButton = this._recorder.injectedScript.document.createElement('x-pw-tool-item'); + this._assertScreenshotButton.title = 'Assert screenshot'; + this._assertScreenshotButton.classList.add('screenshot'); + this._assertScreenshotButton.appendChild(this._recorder.injectedScript.document.createElement('x-div')); + toolsListElement.appendChild(this._assertScreenshotButton); + this._updateVisualPosition(); this._refreshListeners(); } @@ -845,6 +852,15 @@ class Overlay { if (!this._assertValuesToggle.classList.contains('disabled')) this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), + addEventListener(this._assertScreenshotButton, 'click', () => { + if (!this._assertScreenshotButton.classList.contains('disabled')) { + this._recorder.delegate.recordAction?.({ + name: 'assertScreenshot', + signals: [], + }); + this.flashToolSucceeded('assertScreenshot'); + } + }), ]; } @@ -867,6 +883,7 @@ class Overlay { this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); + this._assertScreenshotButton.classList.toggle('disabled', state.mode !== 'recording'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; this._updateVisualPosition(); @@ -877,8 +894,12 @@ class Overlay { this._showOverlay(); } - flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') { - const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle; + flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue' | 'assertScreenshot') { + const element = { + 'assertingVisibility': this._assertVisibilityToggle, + 'assertingValue': this._assertValuesToggle, + 'assertScreenshot': this._assertScreenshotButton, + }[tool]; element.classList.add('succeeded'); setTimeout(() => element.classList.remove('succeeded'), 2000); } diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index 46fadc244a..2b42ae4e2c 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -164,6 +164,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`; return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `// AssertScreenshot(await ${subject}.ScreenshotAsync());`; } } diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index d4ebfdeea4..384584d272 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -152,6 +152,8 @@ export class JavaLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`; return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`; } + case 'assertScreenshot': + return `// assertScreenshot(${subject}.screenshot());`; } } diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index 548e0f6071..e30c6c6e59 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -135,6 +135,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`; return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; } + case 'assertScreenshot': + return `${this._isTest ? '' : '// '}await expect(${subject}).toHaveScreenshot();`; } } diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index b00e02178c..caf74dd57d 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -73,7 +73,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (signals.dialog) formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); - let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`; + let code = this._generateActionCall(subject, action); if (signals.popup) { code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info { @@ -99,7 +99,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { case 'openPage': throw Error('Not reached'); case 'closePage': - return `${subject}.close()`; + return `${this._awaitPrefix}${subject}.close()`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -115,35 +115,37 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsString = formatOptions(options, false); - return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } case 'check': - return `${subject}.${this._asLocator(action.selector)}.check()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.check()`; case 'uncheck': - return `${subject}.${this._asLocator(action.selector)}.uncheck()`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.uncheck()`; case 'fill': - return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; case 'setInputFiles': - return `${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return `${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; } case 'navigate': - return `${subject}.goto(${quote(action.url)})`; + return `${this._awaitPrefix}${subject}.goto(${quote(action.url)})`; case 'select': - return `${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + return `${this._awaitPrefix}${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; case 'assertText': - return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; case 'assertChecked': - return `expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${action.checked ? 'to_be_checked()' : 'not_to_be_checked()'}`; case 'assertVisible': - return `expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).to_be_visible()`; case 'assertValue': { const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`; - return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`; + return `${this._awaitPrefix}expect(${subject}.${this._asLocator(action.selector)}).${assertion}`; } + case 'assertScreenshot': + return `# assert_screenshot(${this._awaitPrefix}${subject}.screenshot())`; } } diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index 3c9720cbc4..ff8c168dbc 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -30,6 +30,7 @@ export type ActionName = 'assertText' | 'assertValue' | 'assertChecked' | + 'assertScreenshot' | 'assertVisible'; export type ActionBase = { @@ -119,7 +120,11 @@ export type AssertVisibleAction = ActionBase & { selector: string, }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction; +export type AssertScreenshotAction = ActionBase & { + name: 'assertScreenshot', +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertScreenshotAction; export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction; // Signals. diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 96a6295f13..4afa8dd51d 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -648,4 +648,18 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); }); + + test('should assert screenshot', async ({ openRecorder }) => { + const recorder = await openRecorder(); + await recorder.setContentAndWait(`
Hello, world
`); + const [sources] = await Promise.all([ + recorder.waitForOutput('JavaScript', 'toHaveScreenshot'), + recorder.page.click('x-pw-tool-item.screenshot'), + ]); + expect.soft(sources.get('JavaScript')!.text).toContain(`await expect(page).toHaveScreenshot()`); + expect.soft(sources.get('Python')!.text).toContain(`# assert_screenshot(page.screenshot())`); + expect.soft(sources.get('Python Async')!.text).toContain(`# assert_screenshot(await page.screenshot())`); + expect.soft(sources.get('Java')!.text).toContain(`// assertScreenshot(page.screenshot());`); + expect.soft(sources.get('C#')!.text).toContain(`// AssertScreenshot(await page.ScreenshotAsync())`); + }); }); From 0a22a86e2e623f88c43e8894eec600cd34cb1784 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 19 Mar 2024 14:08:21 -0700 Subject: [PATCH 120/141] chore: prepare to reuse test server from ui mode (5) (#30005) --- .../src/server/trace/viewer/traceViewer.ts | 6 +- .../src/isomorphic}/testServerConnection.ts | 93 ++++++++++++------- .../src/isomorphic/testServerInterface.ts | 13 ++- .../playwright/src/isomorphic/testTree.ts | 2 + packages/playwright/src/runner/testServer.ts | 10 +- packages/trace-viewer/src/ui/uiModeView.tsx | 11 ++- .../trace-viewer/src/ui/workbenchLoader.tsx | 21 ++--- packages/trace-viewer/src/ui/wsPort.ts | 56 ----------- .../ui-mode-test-update.spec.ts | 6 +- 9 files changed, 100 insertions(+), 118 deletions(-) rename packages/{trace-viewer/src/ui => playwright/src/isomorphic}/testServerConnection.ts (58%) delete mode 100644 packages/trace-viewer/src/ui/wsPort.ts diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index d42138b0ff..aa6ef60271 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -210,10 +210,10 @@ class StdinServer implements Transport { sendEvent?: (method: string, params: any) => void; close?: () => void; - private _loadTrace(url: string) { - this._traceUrl = url; + private _loadTrace(traceUrl: string) { + this._traceUrl = traceUrl; clearTimeout(this._pollTimer); - this.sendEvent?.('loadTrace', { url }); + this.sendEvent?.('loadTraceRequested', { traceUrl }); } private _pollLoadTrace(url: string) { diff --git a/packages/trace-viewer/src/ui/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts similarity index 58% rename from packages/trace-viewer/src/ui/testServerConnection.ts rename to packages/playwright/src/isomorphic/testServerConnection.ts index 45f75a633e..e29e448675 100644 --- a/packages/trace-viewer/src/ui/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -16,67 +16,94 @@ import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface'; import type { Location, TestError } from 'playwright/types/testReporter'; -import { connect } from './wsPort'; -import type { Event } from '@testIsomorphic/events'; -import { EventEmitter } from '@testIsomorphic/events'; +import * as events from './events'; export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents { - readonly onClose: Event; - readonly onListReport: Event; - readonly onTestReport: Event; - readonly onStdio: Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; - readonly onListChanged: Event; - readonly onTestFilesChanged: Event; + readonly onClose: events.Event; + readonly onListReport: events.Event; + readonly onTestReport: events.Event; + readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; + readonly onListChanged: events.Event; + readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; + readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; - private _onCloseEmitter = new EventEmitter(); - private _onListReportEmitter = new EventEmitter(); - private _onTestReportEmitter = new EventEmitter(); - private _onStdioEmitter = new EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); - private _onListChangedEmitter = new EventEmitter(); - private _onTestFilesChangedEmitter = new EventEmitter(); + private _onCloseEmitter = new events.EventEmitter(); + private _onListReportEmitter = new events.EventEmitter(); + private _onTestReportEmitter = new events.EventEmitter(); + private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); + private _onListChangedEmitter = new events.EventEmitter(); + private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); + private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); - private _send: Promise<(method: string, params?: any) => Promise>; + private _lastId = 0; + private _ws: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _connectedPromise: Promise; - constructor() { + constructor(wsURL: string) { this.onClose = this._onCloseEmitter.event; this.onListReport = this._onListReportEmitter.event; this.onTestReport = this._onTestReportEmitter.event; this.onStdio = this._onStdioEmitter.event; this.onListChanged = this._onListChangedEmitter.event; this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; + this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event; - this._send = connect({ - onEvent: (method, params) => this._dispatchEvent(method, params), - onClose: () => this._onCloseEmitter.fire(), + this._ws = new WebSocket(wsURL); + this._ws.addEventListener('message', event => { + const message = JSON.parse(String(event.data)); + const { id, result, error, method, params } = message; + if (id) { + const callback = this._callbacks.get(id); + if (!callback) + return; + this._callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); + } else { + this._dispatchEvent(method, params); + } + }); + const pingInterval = setInterval(() => this._sendMessage('ping').catch(() => {}), 30000); + this._connectedPromise = new Promise((f, r) => { + this._ws.addEventListener('open', () => { + f(); + this._ws.send(JSON.stringify({ method: 'ready' })); + }); + this._ws.addEventListener('error', r); + }); + this._ws.addEventListener('close', () => { + this._onCloseEmitter.fire(); + clearInterval(pingInterval); }); } private async _sendMessage(method: string, params?: any): Promise { - if ((window as any)._sniffProtocolForTest) - (window as any)._sniffProtocolForTest({ method, params }).catch(() => {}); - - const send = await this._send; - const logForTest = (window as any).__logForTest; + const logForTest = (globalThis as any).__logForTest; logForTest?.({ method, params }); - return send(method, params).catch((e: Error) => { - // eslint-disable-next-line no-console - console.error(e); + + await this._connectedPromise; + const id = ++this._lastId; + const message = { id, method, params }; + this._ws.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); }); } private _dispatchEvent(method: string, params?: any) { - if (method === 'close') - this._onCloseEmitter.fire(undefined); - else if (method === 'listReport') + if (method === 'listReport') this._onListReportEmitter.fire(params); else if (method === 'testReport') this._onTestReportEmitter.fire(params); else if (method === 'stdio') this._onStdioEmitter.fire(params); else if (method === 'listChanged') - this._onListChangedEmitter.fire(undefined); + this._onListChangedEmitter.fire(params); else if (method === 'testFilesChanged') - this._onTestFilesChangedEmitter.fire(params.testFileNames); + this._onTestFilesChangedEmitter.fire(params); } async ping(): Promise { diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index dc8a9e9d62..17324a4117 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -80,5 +80,16 @@ export interface TestServerInterfaceEvents { onTestReport: Event; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; onListChanged: Event; - onTestFilesChanged: Event; + onTestFilesChanged: Event<{ testFiles: string[] }>; + onLoadTraceRequested: Event<{ traceUrl: string }>; +} + +export interface TestServerInterfaceEventEmitters { + dispatchEvent(event: 'close', params: {}): void; + dispatchEvent(event: 'listReport', params: any): void; + dispatchEvent(event: 'testReport', params: any): void; + dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void; + dispatchEvent(event: 'listChanged', params: {}): void; + dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void; + dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void; } diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts index bfe932306c..64f414a763 100644 --- a/packages/playwright/src/isomorphic/testTree.ts +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -41,6 +41,7 @@ export type TestCaseItem = TreeItemBase & { children: TestItem[]; test: reporterTypes.TestCase | undefined; project: reporterTypes.FullProject | undefined; + tags: Array; }; export type TestItem = TreeItemBase & { @@ -112,6 +113,7 @@ export class TestTree { status: 'none', project: undefined, test: undefined, + tags: test.tags, }; this._addChild(parentGroup, testCaseItem); } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 3d08fecc5c..c17435402c 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -28,7 +28,7 @@ import ListReporter from '../reporters/list'; import { Multiplexer } from '../reporters/multiplexer'; import { SigIntWatcher } from './sigIntWatcher'; import { Watcher } from '../fsWatcher'; -import type { TestServerInterface } from '../isomorphic/testServerInterface'; +import type { TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface'; import { Runner } from './runner'; import { serializeError } from '../util'; import { prepareErrorStack } from '../reporters/base'; @@ -95,6 +95,7 @@ class TestServerDispatcher implements TestServerInterface { readonly transport: Transport; private _queue = Promise.resolve(); private _globalCleanup: (() => Promise) | undefined; + readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent']; constructor(config: FullConfigInternal) { this._config = config; @@ -106,8 +107,9 @@ class TestServerDispatcher implements TestServerInterface { this._testWatcher = new Watcher('flat', events => { const collector = new Set(); events.forEach(f => collectAffectedTestFiles(f.file, collector)); - this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] }); + this._dispatchEvent('testFilesChanged', { testFiles: [...collector] }); }); + this._dispatchEvent = (method, params) => this.transport.sendEvent?.(method, params); } async ping() {} @@ -252,10 +254,6 @@ class TestServerDispatcher implements TestServerInterface { async closeGracefully() { gracefullyProcessExitDoNotHang(0); } - - _dispatchEvent(method: string, params?: any) { - this.transport.sendEvent?.(method, params); - } } export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index a4bed25a4e..a894920dfb 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -42,7 +42,7 @@ import type { ActionTraceEvent } from '@trace/trace'; import { statusEx, TestTree } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree'; import { testStatusIcon } from './testUtils'; -import { TestServerConnection } from './testServerConnection'; +import { TestServerConnection } from '@testIsomorphic/testServerConnection'; let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {}; let runWatchedTests = (fileNames: string[]) => {}; @@ -90,7 +90,10 @@ export const UIModeView: React.FC<{}> = ({ const inputRef = React.useRef(null); const reloadTests = React.useCallback(() => { - const connection = new TestServerConnection(); + const guid = new URLSearchParams(window.location.search).get('ws'); + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const connection = new TestServerConnection(wsURL.toString()); wireConnectionListeners(connection); connection.onClose(() => setIsDisconnected(true)); setTestServerConnection(connection); @@ -653,8 +656,8 @@ const wireConnectionListeners = (testServerConnection: TestServerConnection) => testServerConnection.listTests({}).catch(() => {}); }); - testServerConnection.onTestFilesChanged(testFiles => { - runWatchedTests(testFiles); + testServerConnection.onTestFilesChanged(params => { + runWatchedTests(params.testFiles); }); testServerConnection.onStdio(params => { diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index bc87a514db..8efa265ae3 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -21,7 +21,7 @@ import { MultiTraceModel } from './modelUtil'; import './workbenchLoader.css'; import { toggleTheme } from '@web/theme'; import { Workbench } from './workbench'; -import { connect } from './wsPort'; +import { TestServerConnection } from '@testIsomorphic/testServerConnection'; export const WorkbenchLoader: React.FunctionComponent<{ }> = () => { @@ -84,17 +84,14 @@ export const WorkbenchLoader: React.FunctionComponent<{ } if (params.has('isServer')) { - connect({ - onEvent(method: string, params?: any) { - if (method === 'loadTrace') { - setTraceURLs(params!.url ? [params!.url] : []); - setDragOver(false); - setProcessingErrorMessage(null); - } - }, - onClose() {} - }).then(sendMessage => { - sendMessage('ready'); + const guid = new URLSearchParams(window.location.search).get('ws'); + const wsURL = new URL(`../${guid}`, window.location.toString()); + wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); + const testServerConnection = new TestServerConnection(wsURL.toString()); + testServerConnection.onLoadTraceRequested(async params => { + setTraceURLs(params.traceUrl ? [params.traceUrl] : []); + setDragOver(false); + setProcessingErrorMessage(null); }); } else if (!newTraceURLs.some(url => url.startsWith('blob:'))) { // Don't re-use blob file URLs on page load (results in Fetch error) diff --git a/packages/trace-viewer/src/ui/wsPort.ts b/packages/trace-viewer/src/ui/wsPort.ts deleted file mode 100644 index 298fa71d17..0000000000 --- a/packages/trace-viewer/src/ui/wsPort.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -let lastId = 0; -let _ws: WebSocket; -const callbacks = new Map void, reject: (arg: Error) => void }>(); - -export async function connect(options: { onEvent: (method: string, params?: any) => void, onClose: () => void }): Promise<(method: string, params?: any) => Promise> { - const guid = new URLSearchParams(window.location.search).get('ws'); - const wsURL = new URL(`../${guid}`, window.location.toString()); - wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); - const ws = new WebSocket(wsURL); - await new Promise(f => ws.addEventListener('open', f)); - ws.addEventListener('close', options.onClose); - ws.addEventListener('message', event => { - const message = JSON.parse(event.data); - const { id, result, error, method, params } = message; - if (id) { - const callback = callbacks.get(id); - if (!callback) - return; - callbacks.delete(id); - if (error) - callback.reject(new Error(error)); - else - callback.resolve(result); - } else { - options.onEvent(method, params); - } - }); - _ws = ws; - setInterval(() => sendMessage('ping').catch(() => {}), 30000); - return sendMessage; -} - -const sendMessage = async (method: string, params?: any): Promise => { - const id = ++lastId; - const message = { id, method, params }; - _ws.send(JSON.stringify(message)); - return new Promise((resolve, reject) => { - callbacks.set(id, { resolve, reject }); - }); -}; diff --git a/tests/playwright-test/ui-mode-test-update.spec.ts b/tests/playwright-test/ui-mode-test-update.spec.ts index b55633c12f..4996c959e6 100644 --- a/tests/playwright-test/ui-mode-test-update.spec.ts +++ b/tests/playwright-test/ui-mode-test-update.spec.ts @@ -169,7 +169,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d `); }); -test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => { +test('should update test locations', async ({ runUITest, writeFiles }) => { const { page } = await runUITest({ 'a.test.ts': ` import { test, expect } from '@playwright/test'; @@ -182,8 +182,8 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile ◯ passes `); - const messages: any = []; - await page.exposeBinding('_sniffProtocolForTest', (_, data) => messages.push(data)); + const messages: any[] = []; + await page.exposeBinding('__logForTest', (source, arg) => messages.push(arg)); const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' }); await passesItemLocator.hover(); From 911fd204cf18aaca4d09a4018755dc7dc6d4a03d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 19 Mar 2024 23:18:20 +0100 Subject: [PATCH 121/141] devops: fix WebView2 tests (#30009) Turns out we were using version 119.0.2151.58 before where the tests are failing. After this change we are using 122.0.2365.92. Fixes https://github.com/microsoft/playwright/issues/29695 Relates https://github.com/actions/runner-images/issues/9538 --- .github/workflows/tests_webview2.yml | 6 ++++++ tests/webview2/globalSetup.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/tests_webview2.yml b/.github/workflows/tests_webview2.yml index 425a7bbc8a..26c97f6062 100644 --- a/.github/workflows/tests_webview2.yml +++ b/.github/workflows/tests_webview2.yml @@ -38,6 +38,12 @@ jobs: - run: npm run build - run: dotnet build working-directory: tests/webview2/webview2-app/ + - name: Update to Evergreen WebView2 Runtime + shell: pwsh + run: | + # See here: https://developer.microsoft.com/en-us/microsoft-edge/webview2/ + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe' + Start-Process -FilePath setup.exe -Verb RunAs -Wait - run: npm run webview2test - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() diff --git a/tests/webview2/globalSetup.ts b/tests/webview2/globalSetup.ts index 7d8a340d9c..030a10401e 100644 --- a/tests/webview2/globalSetup.ts +++ b/tests/webview2/globalSetup.ts @@ -31,6 +31,7 @@ export default async () => { resolve(); })); const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + console.log(`Using version ${browser.version()} WebView2 runtime`); const page = browser.contexts()[0].pages()[0]; await page.goto('data:text/html,'); const chromeVersion = await page.evaluate(() => navigator.userAgent.match(/Chrome\/(.*?) /)[1]); From da2b099b5c505b280aff338ff23dbea25dbd5e94 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 19 Mar 2024 16:54:49 -0700 Subject: [PATCH 122/141] docs: note on locator handler trigger (#30004) Fixes https://github.com/microsoft/playwright/issues/30003 --- docs/src/api/class-page.md | 2 +- packages/playwright-core/types/types.d.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index e462042daa..caea5998b6 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3156,7 +3156,7 @@ This method lets you set up a special function, called a handler, that activates Things to keep in mind: * When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as a part of your normal test flow, instead of using [`method: Page.addLocatorHandler`]. -* Playwright checks for the overlay every time before executing or retrying an action that requires an [actionability check](../actionability.md), or before performing an auto-waiting assertion check. When overlay is visible, Playwright calls the handler first, and then proceeds with the action/assertion. +* Playwright checks for the overlay every time before executing or retrying an action that requires an [actionability check](../actionability.md), or before performing an auto-waiting assertion check. When overlay is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't perform any actions, the handler will not be triggered. * The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts. * You can register multiple handlers. However, only a single handler will be running at a time. Make sure the actions within a handler don't depend on another handler. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 28867d932c..b7be2ff91e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -1796,7 +1796,9 @@ export interface Page { * [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler). * - Playwright checks for the overlay every time before executing or retrying an action that requires an * [actionability check](https://playwright.dev/docs/actionability), or before performing an auto-waiting assertion check. When overlay - * is visible, Playwright calls the handler first, and then proceeds with the action/assertion. + * is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the + * handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't + * perform any actions, the handler will not be triggered. * - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. * If your handler takes too long, it might cause timeouts. * - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the From c712b365ad7a180a2e7106c69fce8117f5b85c75 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 20 Mar 2024 05:31:12 -0700 Subject: [PATCH 123/141] feat(chromium-tip-of-tree): roll to r1203 (#30017) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index d65ae21cfc..5a4f6caadb 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1202", + "revision": "1203", "installByDefault": false, - "browserVersion": "124.0.6357.0" + "browserVersion": "124.0.6367.0" }, { "name": "firefox", From dd0b6f7ec5a65397da71e8055ad4bb31a337e2ac Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 20 Mar 2024 16:56:29 +0100 Subject: [PATCH 124/141] test: generate debug controller channel (#30018) --- tests/config/debugControllerBackend.ts | 43 ++++++-------------------- tests/library/debug-controller.spec.ts | 15 ++++----- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/tests/config/debugControllerBackend.ts b/tests/config/debugControllerBackend.ts index 2d80cb57df..9f67d44e50 100644 --- a/tests/config/debugControllerBackend.ts +++ b/tests/config/debugControllerBackend.ts @@ -16,6 +16,7 @@ import WebSocket from 'ws'; import { EventEmitter } from 'events'; +import type * as channels from '@protocol/channels'; export type ProtocolRequest = { id: number; @@ -109,6 +110,7 @@ export class Backend extends EventEmitter { private static _lastId = 0; private _callbacks = new Map void, reject: (e: Error) => void }>(); private _transport!: WebSocketTransport; + channel: channels.DebugControllerChannel; constructor() { super(); @@ -133,48 +135,23 @@ export class Backend extends EventEmitter { pair.fulfill(message.result); } }; + this.channel = new Proxy(this, { + get: (target, propKey) => { + if (['on', 'once'].includes(String(propKey))) + return target[propKey].bind(target); + return (...args: any) => this._send(String(propKey), ...args); + } + }) as any; } async initialize() { - await this._send('initialize', { codegenId: 'playwright-test', sdkLanguage: 'javascript' }); + await this.channel.initialize({ codegenId: 'playwright-test', sdkLanguage: 'javascript' }); } async close() { await this._transport.closeAndWait(); } - async resetForReuse() { - await this._send('resetForReuse'); - } - - async navigate(params: { url: string }) { - await this._send('navigate', params); - } - - async setMode(params: { mode: 'none' | 'inspecting' | 'recording', language?: string, file?: string, testIdAttributeName?: string }) { - await this._send('setRecorderMode', params); - } - - async setReportStateChanged(params: { enabled: boolean }) { - await this._send('setReportStateChanged', params); - } - - async highlight(params: { selector: string }) { - await this._send('highlight', params); - } - - async hideHighlight() { - await this._send('hideHighlight'); - } - - async resume() { - await this._send('resume'); - } - - async kill() { - await this._send('kill'); - } - private _send(method: string, params: any = {}): Promise { return new Promise((fulfill, reject) => { const id = ++Backend._lastId; diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 779a4a3d09..2da0ee1bf3 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -19,11 +19,12 @@ import { PlaywrightServer } from '../../packages/playwright-core/lib/remote/play import { createGuid } from '../../packages/playwright-core/lib/utils/crypto'; import { Backend } from '../config/debugControllerBackend'; import type { Browser, BrowserContext } from '@playwright/test'; +import type * as channels from '@protocol/channels'; type BrowserWithReuse = Browser & { _newContextForReuse: () => Promise }; type Fixtures = { wsEndpoint: string; - backend: Backend; + backend: channels.DebugControllerChannel; connectedBrowserFactory: () => Promise; connectedBrowser: BrowserWithReuse; }; @@ -40,7 +41,7 @@ const test = baseTest.extend({ const backend = new Backend(); await backend.connect(wsEndpoint); await backend.initialize(); - await use(backend); + await use(backend.channel); await backend.close(); }, connectedBrowserFactory: async ({ wsEndpoint, browserType }, use) => { @@ -70,7 +71,7 @@ test('should pick element', async ({ backend, connectedBrowser }) => { const events = []; backend.on('inspectRequested', event => events.push(event)); - await backend.setMode({ mode: 'inspecting' }); + await backend.setRecorderMode({ mode: 'inspecting' }); const context = await connectedBrowser._newContextForReuse(); const [page] = context.pages(); @@ -90,7 +91,7 @@ test('should pick element', async ({ backend, connectedBrowser }) => { ]); // No events after mode disabled - await backend.setMode({ mode: 'none' }); + await backend.setRecorderMode({ mode: 'none' }); await page.locator('body').click(); expect(events).toHaveLength(2); }); @@ -163,7 +164,7 @@ test('should record', async ({ backend, connectedBrowser }) => { const events = []; backend.on('sourceChanged', event => events.push(event)); - await backend.setMode({ mode: 'recording' }); + await backend.setRecorderMode({ mode: 'recording' }); const context = await connectedBrowser._newContextForReuse(); const [page] = context.pages(); @@ -189,7 +190,7 @@ test('test', async ({ page }) => { }); const length = events.length; // No events after mode disabled - await backend.setMode({ mode: 'none' }); + await backend.setRecorderMode({ mode: 'none' }); await page.getByRole('button').click(); expect(events).toHaveLength(length); }); @@ -207,7 +208,7 @@ test('should record custom data-testid', async ({ backend, connectedBrowser }) = await page.setContent(`
One
`); // 2. "Record at cursor". - await backend.setMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' }); + await backend.setRecorderMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' }); // 3. Record a click action. await page.locator('div').click(); From 980fa90d3c224361845966af84e673c68112927e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 19:10:19 +0100 Subject: [PATCH 125/141] chore(deps): bump black from 23.3.0 to 24.3.0 in /utils/doclint/linting-code-snippets/python (#30022) --- utils/doclint/linting-code-snippets/python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/doclint/linting-code-snippets/python/requirements.txt b/utils/doclint/linting-code-snippets/python/requirements.txt index 3619672425..70f3034c8d 100644 --- a/utils/doclint/linting-code-snippets/python/requirements.txt +++ b/utils/doclint/linting-code-snippets/python/requirements.txt @@ -1 +1 @@ -black==23.3.0 +black==24.3.0 From 2bd3e104ab6aed1d8e0e971c0decab058cc273a1 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 20 Mar 2024 21:15:46 +0100 Subject: [PATCH 126/141] chore: remove run-driver wrapper in driver (#30027) --- utils/build/build-playwright-driver.sh | 21 +++++---------------- utils/build/run-driver-posix.sh | 6 ------ utils/build/run-driver-win.cmd | 4 ---- 3 files changed, 5 insertions(+), 26 deletions(-) delete mode 100755 utils/build/run-driver-posix.sh delete mode 100755 utils/build/run-driver-win.cmd diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index 471d7505f5..5e56bf0896 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -22,7 +22,6 @@ function build { NODE_DIR=$1 SUFFIX=$2 ARCHIVE=$3 - RUN_DRIVER=$4 NODE_URL=https://nodejs.org/dist/v${NODE_VERSION}/${NODE_DIR}.${ARCHIVE} echo "Building playwright-${PACKAGE_VERSION}-${SUFFIX}" @@ -57,16 +56,6 @@ function build { rm package-lock.json cd .. - if [[ "${RUN_DRIVER}" == *".cmd" ]]; then - cp ../../${RUN_DRIVER} ./playwright.cmd - chmod +x ./playwright.cmd - elif [[ "${RUN_DRIVER}" == *".sh" ]]; then - cp ../../${RUN_DRIVER} ./playwright.sh - chmod +x ./playwright.sh - else - echo "Unsupported RUN_DRIVER ${RUN_DRIVER}" - exit 1 - fi # NPM install does intentionally set the modification date back to 1985 for all the files. This confuses language binding # update mechanisms, which expect the modification date to be recent to decide which file to override. See: @@ -77,8 +66,8 @@ function build { zip -q -r ../playwright-${PACKAGE_VERSION}-${SUFFIX}.zip . } -build "node-v${NODE_VERSION}-darwin-x64" "mac" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-darwin-arm64" "mac-arm64" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-linux-x64" "linux" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-linux-arm64" "linux-arm64" "tar.gz" "run-driver-posix.sh" -build "node-v${NODE_VERSION}-win-x64" "win32_x64" "zip" "run-driver-win.cmd" +build "node-v${NODE_VERSION}-darwin-x64" "mac" "tar.gz" +build "node-v${NODE_VERSION}-darwin-arm64" "mac-arm64" "tar.gz" +build "node-v${NODE_VERSION}-linux-x64" "linux" "tar.gz" +build "node-v${NODE_VERSION}-linux-arm64" "linux-arm64" "tar.gz" +build "node-v${NODE_VERSION}-win-x64" "win32_x64" "zip" diff --git a/utils/build/run-driver-posix.sh b/utils/build/run-driver-posix.sh deleted file mode 100755 index af4ed059c5..0000000000 --- a/utils/build/run-driver-posix.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" -if [ -z "$PLAYWRIGHT_NODEJS_PATH" ]; then - PLAYWRIGHT_NODEJS_PATH="$SCRIPT_PATH/node" -fi -"$PLAYWRIGHT_NODEJS_PATH" "$SCRIPT_PATH/package/cli.js" "$@" diff --git a/utils/build/run-driver-win.cmd b/utils/build/run-driver-win.cmd deleted file mode 100755 index 69e98709a0..0000000000 --- a/utils/build/run-driver-win.cmd +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -setlocal -if not defined PLAYWRIGHT_NODEJS_PATH set PLAYWRIGHT_NODEJS_PATH=%~dp0node.exe -"%PLAYWRIGHT_NODEJS_PATH%" "%~dp0package\cli.js" %* \ No newline at end of file From 48ccc9cbcd9c7df20e0a7b144922df3e9dc40789 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 20 Mar 2024 13:43:26 -0700 Subject: [PATCH 127/141] chore: prepare to reuse test server from ui mode (6) (#30008) --- .../playwright/src/isomorphic/teleReceiver.ts | 27 ++++--------- .../src/isomorphic/testServerConnection.ts | 24 +++++------- .../src/isomorphic/testServerInterface.ts | 13 ++++--- packages/playwright/src/reporters/merge.ts | 2 +- packages/playwright/src/runner/reporters.ts | 1 - packages/playwright/src/runner/testServer.ts | 39 ++++++++++++++++--- .../trace-viewer/src/ui/teleSuiteUpdater.ts | 13 +++++-- packages/trace-viewer/src/ui/uiModeView.tsx | 20 +++++----- 8 files changed, 77 insertions(+), 62 deletions(-) diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index 97a2bfa709..e675ce336b 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -135,7 +135,6 @@ export class TeleReporterReceiver { private _reporter: Partial; private _tests = new Map(); private _rootDir!: string; - private _listOnly = false; private _config!: reporterTypes.FullConfig; constructor(reporter: Partial, options: TeleReporterReceiverOptions) { @@ -144,11 +143,16 @@ export class TeleReporterReceiver { this._reporter = reporter; } - dispatch(mode: 'list' | 'test', message: JsonEvent): Promise | void { + reset() { + this._rootSuite.suites = []; + this._rootSuite.tests = []; + this._tests.clear(); + } + + dispatch(message: JsonEvent): Promise | void { const { method, params } = message; if (method === 'onConfigure') { this._onConfigure(params.config); - this._listOnly = mode === 'list'; return; } if (method === 'onProject') { @@ -205,23 +209,6 @@ export class TeleReporterReceiver { // Always update project in watch mode. projectSuite._project = this._parseProject(project); this._mergeSuitesInto(project.suites, projectSuite); - - // Remove deleted tests when listing. Empty suites will be auto-filtered - // in the UI layer. - if (this._listOnly) { - const testIds = new Set(); - const collectIds = (suite: JsonSuite) => { - suite.tests.map(t => t.testId).forEach(testId => testIds.add(testId)); - suite.suites.forEach(collectIds); - }; - project.suites.forEach(collectIds); - - const filterTests = (suite: TeleSuite) => { - suite.tests = suite.tests.filter(t => testIds.has(t.id)); - suite.suites.forEach(filterTests); - }; - filterTests(projectSuite); - } } private _onBegin() { diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index e29e448675..4c8b2ffb8d 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -20,16 +20,14 @@ import * as events from './events'; export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents { readonly onClose: events.Event; - readonly onListReport: events.Event; - readonly onTestReport: events.Event; + readonly onReport: events.Event; readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; readonly onListChanged: events.Event; readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; private _onCloseEmitter = new events.EventEmitter(); - private _onListReportEmitter = new events.EventEmitter(); - private _onTestReportEmitter = new events.EventEmitter(); + private _onReportEmitter = new events.EventEmitter(); private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); private _onListChangedEmitter = new events.EventEmitter(); private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); @@ -42,8 +40,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte constructor(wsURL: string) { this.onClose = this._onCloseEmitter.event; - this.onListReport = this._onListReportEmitter.event; - this.onTestReport = this._onTestReportEmitter.event; + this.onReport = this._onReportEmitter.event; this.onStdio = this._onStdioEmitter.event; this.onListChanged = this._onListChangedEmitter.event; this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; @@ -94,10 +91,8 @@ export class TestServerConnection implements TestServerInterface, TestServerInte } private _dispatchEvent(method: string, params?: any) { - if (method === 'listReport') - this._onListReportEmitter.fire(params); - else if (method === 'testReport') - this._onTestReportEmitter.fire(params); + if (method === 'report') + this._onReportEmitter.fire(params); else if (method === 'stdio') this._onStdioEmitter.fire(params); else if (method === 'listChanged') @@ -142,9 +137,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte return await this._sendMessage('listFiles'); } - async listTests(params: { reporter?: string | undefined; fileNames?: string[] | undefined; }): Promise { - await this._sendMessage('listTests', params); + async listTests(params: { reporter?: string | undefined; fileNames?: string[] | undefined; }): Promise<{ report: any[] }> { + return await this._sendMessage('listTests', params); } + async runTests(params: { reporter?: string | undefined; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise { await this._sendMessage('runTests', params); } @@ -153,8 +149,8 @@ export class TestServerConnection implements TestServerInterface, TestServerInte return await this._sendMessage('findRelatedTestFiles', params); } - async stop(): Promise { - await this._sendMessage('stop'); + async stopTests(): Promise { + await this._sendMessage('stopTests'); } async closeGracefully(): Promise { diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 17324a4117..c10a273ef6 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -47,10 +47,13 @@ export interface TestServerInterface { error?: reporterTypes.TestError; }>; + /** + * Returns list of teleReporter events. + */ listTests(params: { reporter?: string; fileNames?: string[]; - }): Promise; + }): Promise<{ report: any[] }>; runTests(params: { reporter?: string; @@ -69,15 +72,14 @@ export interface TestServerInterface { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[]; }>; - stop(): Promise; + stopTests(): Promise; closeGracefully(): Promise; } export interface TestServerInterfaceEvents { onClose: Event; - onListReport: Event; - onTestReport: Event; + onReport: Event; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; onListChanged: Event; onTestFilesChanged: Event<{ testFiles: string[] }>; @@ -86,8 +88,7 @@ export interface TestServerInterfaceEvents { export interface TestServerInterfaceEventEmitters { dispatchEvent(event: 'close', params: {}): void; - dispatchEvent(event: 'listReport', params: any): void; - dispatchEvent(event: 'testReport', params: any): void; + dispatchEvent(event: 'report', params: any): void; dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void; dispatchEvent(event: 'listChanged', params: {}): void; dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void; diff --git a/packages/playwright/src/reporters/merge.ts b/packages/playwright/src/reporters/merge.ts index 0b21182bba..ef34c409cc 100644 --- a/packages/playwright/src/reporters/merge.ts +++ b/packages/playwright/src/reporters/merge.ts @@ -65,7 +65,7 @@ export async function createMergedReport(config: FullConfigInternal, dir: string for (const event of events) { if (event.method === 'onEnd') printStatus(`building final report`); - await receiver.dispatch('test', event); + await receiver.dispatch(event); if (event.method === 'onEnd') printStatus(`finished building report`); } diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 3ab0ca18c6..7e846c493f 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -89,7 +89,6 @@ function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'ui return { configDir: config.configDir, _send: send, - _mode: mode, }; } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index c17435402c..828c3fbdc0 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import { registry, startTraceViewerServer } from 'playwright-core/lib/server'; import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; import type { Transport, HttpServer } from 'playwright-core/lib/utils'; @@ -172,12 +174,17 @@ class TestServerDispatcher implements TestServerInterface { } async listTests(params: { reporter?: string; fileNames: string[]; }) { - this._queue = this._queue.then(() => this._innerListTests(params)).catch(printInternalError); + let report: any[] = []; + this._queue = this._queue.then(async () => { + report = await this._innerListTests(params); + }).catch(printInternalError); await this._queue; + return { report }; } private async _innerListTests(params: { reporter?: string; fileNames?: string[]; }) { - const wireReporter = await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => this._dispatchEvent('listReport', e)); + const report: any[] = []; + const wireReporter = await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => report.push(e)); const reporter = new InternalReporter(wireReporter); this._config.cliArgs = params.fileNames || []; this._config.cliListOnly = true; @@ -195,7 +202,15 @@ class TestServerDispatcher implements TestServerInterface { projectDirs.add(p.project.testDir); projectOutputs.add(p.project.outputDir); } + + const result = await resolveCtDirs(this._config); + if (result) { + projectDirs.add(result.templateDir); + projectOutputs.add(result.outDir); + } + this._globalWatcher.update([...projectDirs], [...projectOutputs], false); + return report; } async runTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) { @@ -204,7 +219,7 @@ class TestServerDispatcher implements TestServerInterface { } private async _innerRunTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) { - await this.stop(); + await this.stopTests(); const { testIds, projects, locations, grep } = params; const testIdSet = testIds ? new Set(testIds) : null; @@ -215,7 +230,7 @@ class TestServerDispatcher implements TestServerInterface { this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); const reporters = await createReporters(this._config, 'ui'); - reporters.push(await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => this._dispatchEvent('testReport', e))); + reporters.push(await createReporterForTestServer(this._config, params.reporter || require.resolve('./uiModeReporter'), 'list', e => this._dispatchEvent('report', e))); const reporter = new InternalReporter(new Multiplexer(reporters)); const taskRunner = createTaskRunnerForWatch(this._config, reporter); const testRun = new TestRun(this._config, reporter); @@ -246,7 +261,7 @@ class TestServerDispatcher implements TestServerInterface { return runner.findRelatedTestFiles('out-of-process', params.files); } - async stop() { + async stopTests() { this._testRun?.stop?.resolve(); await this._testRun?.run; } @@ -306,3 +321,17 @@ function printInternalError(e: Error) { // eslint-disable-next-line no-console console.error('Internal error:', e); } + +// TODO: remove CT dependency. +export async function resolveCtDirs(config: FullConfigInternal) { + const use = config.config.projects[0].use as any; + const relativeTemplateDir = use.ctTemplateDir || 'playwright'; + const templateDir = await fs.promises.realpath(path.normalize(path.join(config.configDir, relativeTemplateDir))).catch(() => undefined); + if (!templateDir) + return null; + const outDir = use.ctCacheDir ? path.resolve(config.configDir, use.ctCacheDir) : path.resolve(templateDir, '.cache'); + return { + outDir, + templateDir + }; +} diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts index 82e7ab2913..582567e11a 100644 --- a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -128,11 +128,16 @@ export class TeleSuiteUpdater { }; } - dispatch(mode: 'test' | 'list', message: any) { + processListReport(report: any[]) { + this._receiver.reset(); + for (const message of report) + this._receiver.dispatch(message); + } + + processTestReport(message: any) { // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` // before we use it. - if (mode === 'test') - this._lastRunReceiver?.dispatch('test', message)?.catch(() => {}); - this._receiver.dispatch(mode, message)?.catch(() => {}); + this._lastRunReceiver?.dispatch(message)?.catch(() => {}); + this._receiver.dispatch(message)?.catch(() => {}); } } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index a894920dfb..e406747db7 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -184,7 +184,7 @@ export const UIModeView: React.FC<{}> = ({ const onShortcutEvent = (e: KeyboardEvent) => { if (e.code === 'F6') { e.preventDefault(); - testServerConnection?.stop().catch(() => {}); + testServerConnection?.stopTests().catch(() => {}); } else if (e.code === 'F5') { e.preventDefault(); reloadTests(); @@ -278,7 +278,7 @@ export const UIModeView: React.FC<{}> = ({
Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)
} runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}> - testServerConnection?.stop()} disabled={!isRunningTest || isLoading}> + testServerConnection?.stopTests()} disabled={!isRunningTest || isLoading}> { setWatchedTreeIds({ value: new Set() }); setWatchAll(!watchAll); @@ -648,12 +648,14 @@ const refreshRootSuite = async (testServerConnection: TestServerConnection): Pro }, pathSeparator, }); - return testServerConnection.listTests({}); + const { report } = await testServerConnection.listTests({}); + teleSuiteUpdater?.processListReport(report); }; const wireConnectionListeners = (testServerConnection: TestServerConnection) => { - testServerConnection.onListChanged(() => { - testServerConnection.listTests({}).catch(() => {}); + testServerConnection.onListChanged(async () => { + const { report } = await testServerConnection.listTests({}); + teleSuiteUpdater?.processListReport(report); }); testServerConnection.onTestFilesChanged(params => { @@ -669,12 +671,8 @@ const wireConnectionListeners = (testServerConnection: TestServerConnection) => } }); - testServerConnection.onListReport(params => { - teleSuiteUpdater?.dispatch('list', params); - }); - - testServerConnection.onTestReport(params => { - teleSuiteUpdater?.dispatch('test', params); + testServerConnection.onReport(params => { + teleSuiteUpdater?.processTestReport(params); }); xtermDataSource.resize = (cols, rows) => { From 69f2ae1e4d9c22c9414951d3cfbdd9971c7dcd0a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 20 Mar 2024 15:43:29 -0700 Subject: [PATCH 128/141] test: intercepted requests bypass disk cache (#30011) Reference https://github.com/microsoft/playwright/issues/30000 --- tests/page/page-network-response.spec.ts | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 0c698beebd..b856362fa6 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -347,3 +347,55 @@ it('should return body for prefetch script', async ({ page, server, browserName const body = await response.body(); expect(body.toString()).toBe('// Scripts will be pre-fetched'); }); + +it('should bypass disk cache when interception is enabled', async ({ page, server, browserName }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30000' }); + it.fixme(browserName === 'firefox', 'Returns cached response.'); + await page.goto(server.PREFIX + '/frames/one-frame.html'); + await page.route('**/api*', route => route.continue()); + { + const requests = []; + server.setRoute('/api', (req, res) => { + requests.push(req); + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + res.setHeader('cache-control', 'public, max-age=31536000'); + res.end('Hello'); + }); + for (let i = 0; i < 3; i++) { + await it.step(`main frame iteration ${i}`, async () => { + const respPromise = page.waitForResponse('**/api'); + await page.evaluate(async () => { + const response = await fetch('/api'); + return response.status; + }); + const response = await respPromise; + expect(response.status()).toBe(200); + expect(requests.length).toBe(i + 1); + }); + } + } + + { + const requests = []; + server.setRoute('/frame/api', (req, res) => { + requests.push(req); + res.statusCode = 200; + res.setHeader('content-type', 'text/plain'); + res.setHeader('cache-control', 'public, max-age=31536000'); + res.end('Hello'); + }); + for (let i = 0; i < 3; i++) { + await it.step(`subframe iteration ${i}`, async () => { + const respPromise = page.waitForResponse('**/frame/api'); + await page.frame({ url: '**/frame.html' }).evaluate(async () => { + const response = await fetch('/frame/api'); + return response.status; + }); + const response = await respPromise; + expect(response.status()).toBe(200); + expect(requests.length).toBe(i + 1); + }); + } + } +}); From 8a1ff34578cfe734b1b94ea158b81e710d272b34 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 20 Mar 2024 16:00:35 -0700 Subject: [PATCH 129/141] chore: split ui mode view into files (#30029) --- .../src/isomorphic/testServerConnection.ts | 25 + .../trace-viewer/src/ui/uiModeFiltersView.css | 71 +++ .../trace-viewer/src/ui/uiModeFiltersView.tsx | 94 ++++ packages/trace-viewer/src/ui/uiModeModel.ts | 25 + .../src/ui/uiModeTestListView.css | 41 ++ .../src/ui/uiModeTestListView.tsx | 167 +++++++ .../trace-viewer/src/ui/uiModeTraceView.tsx | 114 +++++ packages/trace-viewer/src/ui/uiModeView.css | 82 --- packages/trace-viewer/src/ui/uiModeView.tsx | 466 ++++-------------- packages/web/src/components/listView.tsx | 2 +- 10 files changed, 622 insertions(+), 465 deletions(-) create mode 100644 packages/trace-viewer/src/ui/uiModeFiltersView.css create mode 100644 packages/trace-viewer/src/ui/uiModeFiltersView.tsx create mode 100644 packages/trace-viewer/src/ui/uiModeModel.ts create mode 100644 packages/trace-viewer/src/ui/uiModeTestListView.css create mode 100644 packages/trace-viewer/src/ui/uiModeTestListView.tsx create mode 100644 packages/trace-viewer/src/ui/uiModeTraceView.tsx diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 4c8b2ffb8d..9c3f84e1ec 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -90,6 +90,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte }); } + private _sendMessageNoReply(method: string, params?: any) { + this._sendMessage(method, params).catch(() => {}); + } + private _dispatchEvent(method: string, params?: any) { if (method === 'report') this._onReportEmitter.fire(params); @@ -105,18 +109,34 @@ export class TestServerConnection implements TestServerInterface, TestServerInte await this._sendMessage('ping'); } + async pingNoReply() { + await this._sendMessageNoReply('ping'); + } + async watch(params: { fileNames: string[]; }): Promise { await this._sendMessage('watch', params); } + watchNoReply(params: { fileNames: string[]; }) { + this._sendMessageNoReply('watch', params); + } + async open(params: { location: Location; }): Promise { await this._sendMessage('open', params); } + openNoReply(params: { location: Location; }) { + this._sendMessageNoReply('open', params); + } + async resizeTerminal(params: { cols: number; rows: number; }): Promise { await this._sendMessage('resizeTerminal', params); } + resizeTerminalNoReply(params: { cols: number; rows: number; }) { + this._sendMessageNoReply('resizeTerminal', params); + } + async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> { return await this._sendMessage('checkBrowsers'); } @@ -153,6 +173,11 @@ export class TestServerConnection implements TestServerInterface, TestServerInte await this._sendMessage('stopTests'); } + stopTestsNoReply() { + this._sendMessageNoReply('stopTests'); + } + + async closeGracefully(): Promise { await this._sendMessage('closeGracefully'); } diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.css b/packages/trace-viewer/src/ui/uiModeFiltersView.css new file mode 100644 index 0000000000..5967da3e23 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.css @@ -0,0 +1,71 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.filters { + flex: none; + display: flex; + flex-direction: column; + margin: 2px 0; +} + +.filter-list { + padding: 0 10px 10px 10px; + user-select: none; +} + +.filter-title, +.filter-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; +} + +.filter-label { + color: var(--vscode-disabledForeground); +} + +.filter-summary { + line-height: 24px; + margin-left: 24px; +} + +.filter-summary .filter-label { + margin-left: 5px; +} + +.filter-entry { + line-height: 24px; +} + +.filter-entry label { + display: flex; + align-items: center; + cursor: pointer; +} + +.filter-entry input { + flex: none; + display: flex; + align-items: center; + cursor: pointer; +} + +.filter-entry label div { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.tsx b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx new file mode 100644 index 0000000000..a6cff6ecf8 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@web/common.css'; +import { Expandable } from '@web/components/expandable'; +import '@web/third_party/vscode/codicon.css'; +import { settings } from '@web/uiUtils'; +import React from 'react'; +import './uiModeFiltersView.css'; +import type { TestModel } from './uiModeModel'; + +export const FiltersView: React.FC<{ + filterText: string; + setFilterText: (text: string) => void; + statusFilters: Map; + setStatusFilters: (filters: Map) => void; + projectFilters: Map; + setProjectFilters: (filters: Map) => void; + testModel: TestModel | undefined, + runTests: () => void; +}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { + const [expanded, setExpanded] = React.useState(false); + const inputRef = React.useRef(null); + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all'; + const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all'; + return
+ { + setFilterText(e.target.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') + runTests(); + }} />}> + +
setExpanded(!expanded)}> + Status: {statusLine} + Projects: {projectsLine} +
+ {expanded &&
+
+ {[...statusFilters.entries()].map(([status, value]) => { + return
+ +
; + })} +
+
+ {[...projectFilters.entries()].map(([projectName, value]) => { + return
+ +
; + })} +
+
} +
; +}; diff --git a/packages/trace-viewer/src/ui/uiModeModel.ts b/packages/trace-viewer/src/ui/uiModeModel.ts new file mode 100644 index 0000000000..f2656901d7 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeModel.ts @@ -0,0 +1,25 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import type * as reporterTypes from 'playwright/types/testReporter'; + +export type TestModel = { + config: reporterTypes.FullConfig | undefined; + rootSuite: reporterTypes.Suite | undefined; + loadErrors: reporterTypes.TestError[]; +}; + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.css b/packages/trace-viewer/src/ui/uiModeTestListView.css new file mode 100644 index 0000000000..ae6fd624ee --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.css @@ -0,0 +1,41 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.ui-mode-list-item { + flex: auto; +} + +.ui-mode-list-item-title { + flex: auto; + text-overflow: ellipsis; + overflow: hidden; +} + +.ui-mode-list-item-time { + flex: none; + color: var(--vscode-editorCodeLens-foreground); + margin: 0 4px; + user-select: none; +} + +.tests-list-view .list-view-entry.selected .ui-mode-list-item-time, +.tests-list-view .list-view-entry.highlighted .ui-mode-list-item-time { + display: none; +} + +.tests-list-view .list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) { + display: none; +} diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx new file mode 100644 index 0000000000..422341f364 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -0,0 +1,167 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { TreeItem } from '@testIsomorphic/testTree'; +import type { TestTree } from '@testIsomorphic/testTree'; +import '@web/common.css'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton } from '@web/components/toolbarButton'; +import type { TreeState } from '@web/components/treeView'; +import { TreeView } from '@web/components/treeView'; +import '@web/third_party/vscode/codicon.css'; +import { msToString } from '@web/uiUtils'; +import type * as reporterTypes from 'playwright/types/testReporter'; +import React from 'react'; +import type { SourceLocation } from './modelUtil'; +import { testStatusIcon } from './testUtils'; +import type { TestModel } from './uiModeModel'; +import './uiModeTestListView.css'; +import type { TestServerConnection } from '@testIsomorphic/testServerConnection'; + +const TestTreeView = TreeView; + +export const TestListView: React.FC<{ + filterText: string, + testTree: TestTree, + testServerConnection: TestServerConnection | undefined, + testModel: TestModel, + runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, + runningState?: { testIds: Set, itemSelectedByUser?: boolean }, + watchAll: boolean, + watchedTreeIds: { value: Set }, + setWatchedTreeIds: (ids: { value: Set }) => void, + isLoading?: boolean, + onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, + requestedCollapseAllCount: number, +}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => { + const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); + const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); + const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); + + // Look for a first failure within the run batch to select it. + React.useEffect(() => { + // If collapse was requested, clear the expanded items and return w/o selected item. + if (collapseAllCount !== requestedCollapseAllCount) { + treeState.expandedItems.clear(); + for (const item of testTree.flatTreeItems()) + treeState.expandedItems.set(item.id, false); + setCollapseAllCount(requestedCollapseAllCount); + setSelectedTreeItemId(undefined); + setTreeState({ ...treeState }); + return; + } + + if (!runningState || runningState.itemSelectedByUser) + return; + let selectedTreeItem: TreeItem | undefined; + const visit = (treeItem: TreeItem) => { + treeItem.children.forEach(visit); + if (selectedTreeItem) + return; + if (treeItem.status === 'failed') { + if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) + selectedTreeItem = treeItem; + else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) + selectedTreeItem = treeItem; + } + }; + visit(testTree.rootItem); + + if (selectedTreeItem) + setSelectedTreeItemId(selectedTreeItem.id); + }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); + + // Compute selected item. + const { selectedTreeItem } = React.useMemo(() => { + const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; + let testFile: SourceLocation | undefined; + if (selectedTreeItem) { + testFile = { + file: selectedTreeItem.location.file, + line: selectedTreeItem.location.line, + source: { + errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })), + content: undefined, + } + }; + } + let selectedTest: reporterTypes.TestCase | undefined; + if (selectedTreeItem?.kind === 'test') + selectedTest = selectedTreeItem.test; + else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) + selectedTest = selectedTreeItem.tests[0]; + onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); + return { selectedTreeItem }; + }, [onItemSelected, selectedTreeItemId, testModel, testTree]); + + // Update watch all. + React.useEffect(() => { + if (isLoading) + return; + if (watchAll) { + testServerConnection?.watchNoReply({ fileNames: testTree.fileNames() }); + } else { + const fileNames = new Set(); + for (const itemId of watchedTreeIds.value) { + const treeItem = testTree.treeItemById(itemId); + const fileName = treeItem?.location.file; + if (fileName) + fileNames.add(fileName); + } + testServerConnection?.watchNoReply({ fileNames: [...fileNames] }); + } + }, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]); + + const runTreeItem = (treeItem: TreeItem) => { + setSelectedTreeItemId(treeItem.id); + runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); + }; + + return { + return
+
{treeItem.title}
+ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} + + runTreeItem(treeItem)} disabled={!!runningState}> + testServerConnection?.openNoReply({ location: treeItem.location })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + {!watchAll && { + if (watchedTreeIds.value.has(treeItem.id)) + watchedTreeIds.value.delete(treeItem.id); + else + watchedTreeIds.value.add(treeItem.id); + setWatchedTreeIds({ ...watchedTreeIds }); + }} toggled={watchedTreeIds.value.has(treeItem.id)}>} + +
; + }} + icon={treeItem => testStatusIcon(treeItem.status)} + selectedItem={selectedTreeItem} + onAccepted={runTreeItem} + onSelected={treeItem => { + if (runningState) + runningState.itemSelectedByUser = true; + setSelectedTreeItemId(treeItem.id); + }} + isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} + autoExpandDepth={filterText ? 5 : 1} + noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; +}; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx new file mode 100644 index 0000000000..86fd3fbd8c --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -0,0 +1,114 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { artifactsFolderName } from '@testIsomorphic/folders'; +import type { TreeItem } from '@testIsomorphic/testTree'; +import type { ActionTraceEvent } from '@trace/trace'; +import '@web/common.css'; +import '@web/third_party/vscode/codicon.css'; +import type * as reporterTypes from 'playwright/types/testReporter'; +import React from 'react'; +import type { ContextEntry } from '../entries'; +import type { SourceLocation } from './modelUtil'; +import { idForAction, MultiTraceModel } from './modelUtil'; +import { Workbench } from './workbench'; + +export const TraceView: React.FC<{ + item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, + rootDir?: string, +}> = ({ item, rootDir }) => { + const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); + const [counter, setCounter] = React.useState(0); + const pollTimer = React.useRef(null); + + const { outputDir } = React.useMemo(() => { + const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined; + return { outputDir }; + }, [item]); + + // Preserve user selection upon live-reloading trace model by persisting the action id. + // This avoids auto-selection of the last action every time we reload the model. + const [selectedActionId, setSelectedActionId] = React.useState(); + const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]); + const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined; + + React.useEffect(() => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + + const result = item.testCase?.results[0]; + if (!result) { + setModel(undefined); + return; + } + + // Test finished. + const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); + if (attachment && attachment.path) { + loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false })); + return; + } + + if (!outputDir) { + setModel(undefined); + return; + } + + const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`; + // Start polling running test. + pollTimer.current = setTimeout(async () => { + try { + const model = await loadSingleTraceFile(traceLocation); + setModel({ model, isLive: true }); + } catch { + setModel(undefined); + } finally { + setCounter(counter + 1); + } + }, 500); + return () => { + if (pollTimer.current) + clearTimeout(pollTimer.current); + }; + }, [outputDir, item, setModel, counter, setCounter]); + + return ; +}; + +const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { + for (let suite: reporterTypes.Suite | undefined = testCase.parent; suite; suite = suite.parent) { + if (suite.project()) + return suite.project()?.outputDir; + } + return undefined; +}; + +async function loadSingleTraceFile(url: string): Promise { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`contexts?${params.toString()}`); + const contextEntries = await response.json() as ContextEntry[]; + return new MultiTraceModel(contextEntries); +} diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index 3c18145805..a45e586c52 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -30,28 +30,6 @@ color: var(--vscode-debugIcon-stopForeground); } -.ui-mode-list-item { - flex: auto; -} - -.ui-mode-list-item-title { - flex: auto; - text-overflow: ellipsis; - overflow: hidden; -} - -.ui-mode-list-item-time { - flex: none; - color: var(--vscode-editorCodeLens-foreground); - margin: 0 4px; - user-select: none; -} - -.list-view-entry.selected .ui-mode-list-item-time, -.list-view-entry.highlighted .ui-mode-list-item-time { - display: none; -} - .ui-mode .section-title { display: flex; flex: auto; @@ -111,10 +89,6 @@ text-overflow: ellipsis; } -.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) { - display: none; -} - .ui-mode-sidebar input[type=search] { flex: auto; padding: 0 5px; @@ -125,59 +99,3 @@ color: var(--vscode-input-foreground); background-color: var(--vscode-input-background); } - -.filters { - flex: none; - display: flex; - flex-direction: column; - margin: 2px 0; -} - -.filter-list { - padding: 0 10px 10px 10px; - user-select: none; -} - -.filter-title, -.filter-summary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - user-select: none; - cursor: pointer; -} - -.filter-label { - color: var(--vscode-disabledForeground); -} - -.filter-summary { - line-height: 24px; - margin-left: 24px; -} - -.filter-summary .filter-label { - margin-left: 5px; -} - -.filter-entry { - line-height: 24px; -} - -.filter-entry label { - display: flex; - align-items: center; - cursor: pointer; -} - -.filter-entry input { - flex: none; - display: flex; - align-items: center; - cursor: pointer; -} - -.filter-entry label div { - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index e406747db7..eb13d868ba 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -15,37 +15,34 @@ */ import '@web/third_party/vscode/codicon.css'; -import { Workbench } from './workbench'; import '@web/common.css'; import React from 'react'; -import { TreeView } from '@web/components/treeView'; -import type { TreeState } from '@web/components/treeView'; import { baseFullConfig, TeleSuite } from '@testIsomorphic/teleReceiver'; import { TeleSuiteUpdater } from './teleSuiteUpdater'; import type { Progress } from './teleSuiteUpdater'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; -import { idForAction, MultiTraceModel } from './modelUtil'; import type { SourceLocation } from './modelUtil'; import './uiModeView.css'; import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; -import type { ContextEntry } from '../entries'; import type { XtermDataSource } from '@web/components/xtermWrapper'; import { XtermWrapper } from '@web/components/xtermWrapper'; -import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; -import { artifactsFolderName } from '@testIsomorphic/folders'; -import { msToString, settings, useSetting } from '@web/uiUtils'; -import type { ActionTraceEvent } from '@trace/trace'; +import { settings, useSetting } from '@web/uiUtils'; import { statusEx, TestTree } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree'; -import { testStatusIcon } from './testUtils'; +import type { Disposable } from '@testIsomorphic/events'; import { TestServerConnection } from '@testIsomorphic/testServerConnection'; +import { pathSeparator } from './uiModeModel'; +import type { TestModel } from './uiModeModel'; +import { FiltersView } from './uiModeFiltersView'; +import { TestListView } from './uiModeTestListView'; +import { TraceView } from './uiModeTraceView'; let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {}; -let runWatchedTests = (fileNames: string[]) => {}; +// let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -55,17 +52,10 @@ const xtermDataSource: XtermDataSource = { resize: () => {}, }; -type TestModel = { - config: reporterTypes.FullConfig | undefined; - rootSuite: reporterTypes.Suite | undefined; - loadErrors: reporterTypes.TestError[]; -}; - export const UIModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); - const [statusFilters, setStatusFilters] = React.useState>(new Map([ ['passed', false], ['failed', false], @@ -94,8 +84,6 @@ export const UIModeView: React.FC<{}> = ({ const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); const connection = new TestServerConnection(wsURL.toString()); - wireConnectionListeners(connection); - connection.onClose(() => setIsDisconnected(true)); setTestServerConnection(connection); setIsLoading(true); setWatchedTreeIds({ value: new Set() }); @@ -110,6 +98,19 @@ export const UIModeView: React.FC<{}> = ({ })(); }, []); + React.useEffect(() => { + if (!testServerConnection) + return; + const disposables = [ + ...wireConnectionListeners(testServerConnection), + testServerConnection.onClose(() => setIsDisconnected(true)) + ]; + return () => { + for (const disposable of disposables) + disposable.dispose(); + }; + }, [testServerConnection]); + React.useEffect(() => { inputRef.current?.focus(); setIsLoading(true); @@ -137,6 +138,16 @@ export const UIModeView: React.FC<{}> = ({ setProgress(undefined); }, [projectFilters, runningState]); + const { testTree } = React.useMemo(() => { + const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); + testTree.filterTree(filterText, statusFilters, runningState?.testIds); + testTree.sortAndPropagateStatus(); + testTree.shortenRoot(); + testTree.flattenForSingleProject(); + setVisibleTestIds(testTree.testIds()); + return { testTree }; + }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); + const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set) => { if (!testServerConnection) return; @@ -178,13 +189,41 @@ export const UIModeView: React.FC<{}> = ({ }); }, [projectFilters, runningState, testModel, testServerConnection]); + React.useEffect(() => { + if (!testServerConnection) + return; + const disposable = testServerConnection.onTestFilesChanged(params => { + const testIds: string[] = []; + const set = new Set(params.testFiles); + if (watchAll) { + const visit = (treeItem: TreeItem) => { + const fileName = treeItem.location.file; + if (fileName && set.has(fileName)) + testIds.push(...testTree.collectTestIds(treeItem)); + if (treeItem.kind === 'group' && treeItem.subKind === 'folder') + treeItem.children.forEach(visit); + }; + visit(testTree.rootItem); + } else { + for (const treeId of watchedTreeIds.value) { + const treeItem = testTree.treeItemById(treeId); + const fileName = treeItem?.location.file; + if (fileName && set.has(fileName)) + testIds.push(...testTree.collectTestIds(treeItem)); + } + } + runTests('queue-if-busy', new Set(testIds)); + }); + return () => disposable.dispose(); + }, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); + React.useEffect(() => { if (!testServerConnection) return; const onShortcutEvent = (e: KeyboardEvent) => { if (e.code === 'F6') { e.preventDefault(); - testServerConnection?.stopTests().catch(() => {}); + testServerConnection?.stopTestsNoReply(); } else if (e.code === 'F5') { e.preventDefault(); reloadTests(); @@ -287,339 +326,24 @@ export const UIModeView: React.FC<{}> = ({ setCollapseAllCount(collapseAllCount + 1); }} />
- + requestedCollapseAllCount={collapseAllCount} /> ; }; -const FiltersView: React.FC<{ - filterText: string; - setFilterText: (text: string) => void; - statusFilters: Map; - setStatusFilters: (filters: Map) => void; - projectFilters: Map; - setProjectFilters: (filters: Map) => void; - testModel: TestModel | undefined, - runTests: () => void; -}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { - const [expanded, setExpanded] = React.useState(false); - const inputRef = React.useRef(null); - React.useEffect(() => { - inputRef.current?.focus(); - }, []); - - const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all'; - const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all'; - return
- { - setFilterText(e.target.value); - }} - onKeyDown={e => { - if (e.key === 'Enter') - runTests(); - }} />}> - -
setExpanded(!expanded)}> - Status: {statusLine} - Projects: {projectsLine} -
- {expanded &&
-
- {[...statusFilters.entries()].map(([status, value]) => { - return
- -
; - })} -
-
- {[...projectFilters.entries()].map(([projectName, value]) => { - return
- -
; - })} -
-
} -
; -}; - -const TestTreeView = TreeView; - -const TestList: React.FC<{ - statusFilters: Map, - projectFilters: Map, - filterText: string, - testModel: TestModel, - runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, - runningState?: { testIds: Set, itemSelectedByUser?: boolean }, - watchAll: boolean, - watchedTreeIds: { value: Set }, - setWatchedTreeIds: (ids: { value: Set }) => void, - isLoading?: boolean, - setVisibleTestIds: (testIds: Set) => void, - onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, - requestedCollapseAllCount: number, - testServerConnection: TestServerConnection | undefined, -}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount, testServerConnection }) => { - const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); - const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); - - // Build the test tree. - const { testTree } = React.useMemo(() => { - const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); - testTree.filterTree(filterText, statusFilters, runningState?.testIds); - testTree.sortAndPropagateStatus(); - testTree.shortenRoot(); - testTree.flattenForSingleProject(); - setVisibleTestIds(testTree.testIds()); - return { testTree }; - }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); - - // Look for a first failure within the run batch to select it. - React.useEffect(() => { - // If collapse was requested, clear the expanded items and return w/o selected item. - if (collapseAllCount !== requestedCollapseAllCount) { - treeState.expandedItems.clear(); - for (const item of testTree.flatTreeItems()) - treeState.expandedItems.set(item.id, false); - setCollapseAllCount(requestedCollapseAllCount); - setSelectedTreeItemId(undefined); - setTreeState({ ...treeState }); - return; - } - - if (!runningState || runningState.itemSelectedByUser) - return; - let selectedTreeItem: TreeItem | undefined; - const visit = (treeItem: TreeItem) => { - treeItem.children.forEach(visit); - if (selectedTreeItem) - return; - if (treeItem.status === 'failed') { - if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) - selectedTreeItem = treeItem; - else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) - selectedTreeItem = treeItem; - } - }; - visit(testTree.rootItem); - - if (selectedTreeItem) - setSelectedTreeItemId(selectedTreeItem.id); - }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); - - // Compute selected item. - const { selectedTreeItem } = React.useMemo(() => { - const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; - let testFile: SourceLocation | undefined; - if (selectedTreeItem) { - testFile = { - file: selectedTreeItem.location.file, - line: selectedTreeItem.location.line, - source: { - errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })), - content: undefined, - } - }; - } - let selectedTest: reporterTypes.TestCase | undefined; - if (selectedTreeItem?.kind === 'test') - selectedTest = selectedTreeItem.test; - else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) - selectedTest = selectedTreeItem.tests[0]; - onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); - return { selectedTreeItem }; - }, [onItemSelected, selectedTreeItemId, testModel, testTree]); - - // Update watch all. - React.useEffect(() => { - if (isLoading || !testServerConnection) - return; - if (watchAll) { - testServerConnection.watch({ fileNames: testTree.fileNames() }).catch(() => {}); - } else { - const fileNames = new Set(); - for (const itemId of watchedTreeIds.value) { - const treeItem = testTree.treeItemById(itemId); - const fileName = treeItem?.location.file; - if (fileName) - fileNames.add(fileName); - } - testServerConnection.watch({ fileNames: [...fileNames] }).catch(() => {}); - } - }, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]); - - const runTreeItem = (treeItem: TreeItem) => { - setSelectedTreeItemId(treeItem.id); - runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); - }; - - runWatchedTests = (changedTestFiles: string[]) => { - const testIds: string[] = []; - const set = new Set(changedTestFiles); - if (watchAll) { - const visit = (treeItem: TreeItem) => { - const fileName = treeItem.location.file; - if (fileName && set.has(fileName)) - testIds.push(...testTree.collectTestIds(treeItem)); - if (treeItem.kind === 'group' && treeItem.subKind === 'folder') - treeItem.children.forEach(visit); - }; - visit(testTree.rootItem); - } else { - for (const treeId of watchedTreeIds.value) { - const treeItem = testTree.treeItemById(treeId); - const fileName = treeItem?.location.file; - if (fileName && set.has(fileName)) - testIds.push(...testTree.collectTestIds(treeItem)); - } - } - runTests('queue-if-busy', new Set(testIds)); - }; - - return { - return
-
{treeItem.title}
- {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} - - runTreeItem(treeItem)} disabled={!!runningState}> - testServerConnection?.open({ location: treeItem.location }).catch(() => {})} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> - {!watchAll && { - if (watchedTreeIds.value.has(treeItem.id)) - watchedTreeIds.value.delete(treeItem.id); - else - watchedTreeIds.value.add(treeItem.id); - setWatchedTreeIds({ ...watchedTreeIds }); - }} toggled={watchedTreeIds.value.has(treeItem.id)}>} - -
; - }} - icon={treeItem => testStatusIcon(treeItem.status)} - selectedItem={selectedTreeItem} - onAccepted={runTreeItem} - onSelected={treeItem => { - if (runningState) - runningState.itemSelectedByUser = true; - setSelectedTreeItemId(treeItem.id); - }} - isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} - autoExpandDepth={filterText ? 5 : 1} - noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; -}; - -const TraceView: React.FC<{ - item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, - rootDir?: string, -}> = ({ item, rootDir }) => { - const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); - const [counter, setCounter] = React.useState(0); - const pollTimer = React.useRef(null); - - const { outputDir } = React.useMemo(() => { - const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined; - return { outputDir }; - }, [item]); - - // Preserve user selection upon live-reloading trace model by persisting the action id. - // This avoids auto-selection of the last action every time we reload the model. - const [selectedActionId, setSelectedActionId] = React.useState(); - const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]); - const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined; - - React.useEffect(() => { - if (pollTimer.current) - clearTimeout(pollTimer.current); - - const result = item.testCase?.results[0]; - if (!result) { - setModel(undefined); - return; - } - - // Test finished. - const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace'); - if (attachment && attachment.path) { - loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false })); - return; - } - - if (!outputDir) { - setModel(undefined); - return; - } - - const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`; - // Start polling running test. - pollTimer.current = setTimeout(async () => { - try { - const model = await loadSingleTraceFile(traceLocation); - setModel({ model, isLive: true }); - } catch { - setModel(undefined); - } finally { - setCounter(counter + 1); - } - }, 500); - return () => { - if (pollTimer.current) - clearTimeout(pollTimer.current); - }; - }, [outputDir, item, setModel, counter, setCounter]); - - return ; -}; - let teleSuiteUpdater: TeleSuiteUpdater | undefined; let throttleTimer: NodeJS.Timeout | undefined; @@ -652,49 +376,27 @@ const refreshRootSuite = async (testServerConnection: TestServerConnection): Pro teleSuiteUpdater?.processListReport(report); }; -const wireConnectionListeners = (testServerConnection: TestServerConnection) => { - testServerConnection.onListChanged(async () => { - const { report } = await testServerConnection.listTests({}); - teleSuiteUpdater?.processListReport(report); - }); - - testServerConnection.onTestFilesChanged(params => { - runWatchedTests(params.testFiles); - }); - - testServerConnection.onStdio(params => { - if (params.buffer) { - const data = atob(params.buffer); - xtermDataSource.write(data); - } else { - xtermDataSource.write(params.text!); - } - }); - - testServerConnection.onReport(params => { - teleSuiteUpdater?.processTestReport(params); - }); - +const wireConnectionListeners = (testServerConnection: TestServerConnection): Disposable[] => { + const disposables: Disposable[] = [ + testServerConnection.onListChanged(async () => { + const { report } = await testServerConnection.listTests({}); + teleSuiteUpdater?.processListReport(report); + }), + testServerConnection.onStdio(params => { + if (params.buffer) { + const data = atob(params.buffer); + xtermDataSource.write(data); + } else { + xtermDataSource.write(params.text!); + } + }), + testServerConnection.onReport(params => { + teleSuiteUpdater?.processTestReport(params); + }), + ]; xtermDataSource.resize = (cols, rows) => { xtermSize = { cols, rows }; - testServerConnection.resizeTerminal({ cols, rows }).catch(() => {}); + testServerConnection.resizeTerminalNoReply({ cols, rows }); }; + return disposables; }; - -const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { - for (let suite: reporterTypes.Suite | undefined = testCase.parent; suite; suite = suite.parent) { - if (suite.project()) - return suite.project()?.outputDir; - } - return undefined; -}; - -async function loadSingleTraceFile(url: string): Promise { - const params = new URLSearchParams(); - params.set('trace', url); - const response = await fetch(`contexts?${params.toString()}`); - const contextEntries = await response.json() as ContextEntry[]; - return new MultiTraceModel(contextEntries); -} - -export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index a9bd9f8fb3..323e2cf506 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -85,7 +85,7 @@ export function ListView({ itemListRef.current.scrollTop = scrollPositions.get(name) || 0; }, [name]); - return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}> + return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}>
Date: Wed, 20 Mar 2024 16:38:28 -0700 Subject: [PATCH 130/141] fix(codegen): import re in python (#30026) Fixes https://github.com/microsoft/playwright/issues/30019 --- .../src/server/recorder/python.ts | 5 +++-- .../inspector/cli-codegen-pytest.spec.ts | 8 +++++--- .../inspector/cli-codegen-python-async.spec.ts | 12 ++++++------ .../inspector/cli-codegen-python.spec.ts | 18 ++++++++++++------ 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index caf74dd57d..d9ddc0fd9c 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -164,7 +164,7 @@ def browser_context_args(browser_context_args, playwright) { return {${contextOptions}} } ` : ''; - formatter.add(`${options.deviceName ? 'import pytest\n' : ''} + formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re from playwright.sync_api import Page, expect ${fixture} @@ -172,7 +172,7 @@ def test_example(page: Page) -> None {`); } else if (this._isAsync) { formatter.add(` import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -181,6 +181,7 @@ async def run(playwright: Playwright) -> None { context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); } else { formatter.add(` +import re from playwright.sync_api import Playwright, sync_playwright, expect diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index 42a1eefa2e..e1bd608ef5 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -22,7 +22,8 @@ const emptyHTML = new URL('file://' + path.join(__dirname, '..', '..', 'assets', test('should print the correct imports and context options', async ({ runCLI }) => { const cli = runCLI(['--target=python-pytest', emptyHTML]); - const expectedResult = `from playwright.sync_api import Page, expect + const expectedResult = `import re +from playwright.sync_api import Page, expect def test_example(page: Page) -> None:`; @@ -39,7 +40,7 @@ test('should print the correct context options when using a device and lang', as await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); expect(content.toString()).toBe(`import pytest - +import re from playwright.sync_api import Page, expect @@ -60,7 +61,8 @@ test('should save the codegen output to a file if specified', async ({ runCLI }, }); await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); - expect(content.toString()).toBe(`from playwright.sync_api import Page, expect + expect(content.toString()).toBe(`import re +from playwright.sync_api import Page, expect def test_example(page: Page) -> None: diff --git a/tests/library/inspector/cli-codegen-python-async.spec.ts b/tests/library/inspector/cli-codegen-python-async.spec.ts index d765d55d9c..02647c5508 100644 --- a/tests/library/inspector/cli-codegen-python-async.spec.ts +++ b/tests/library/inspector/cli-codegen-python-async.spec.ts @@ -26,7 +26,7 @@ const launchOptions = (channel: string) => { test('should print the correct imports and context options', async ({ browserName, channel, runCLI }) => { const cli = runCLI(['--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -39,7 +39,7 @@ async def run(playwright: Playwright) -> None: test('should print the correct context options for custom settings', async ({ browserName, channel, runCLI }) => { const cli = runCLI(['--color-scheme=light', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -54,7 +54,7 @@ test('should print the correct context options when using a device', async ({ br const cli = runCLI(['--device=Pixel 2', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -69,7 +69,7 @@ test('should print the correct context options when using a device and additiona const cli = runCLI(['--color-scheme=light', '--device=iPhone 11', '--target=python-async', emptyHTML]); const expectedResult = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -87,7 +87,7 @@ test('should save the codegen output to a file if specified', async ({ browserNa await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); expect(content.toString()).toBe(`import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect @@ -118,7 +118,7 @@ test('should print load/save storage_state', async ({ browserName, channel, runC await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, '--target=python-async', emptyHTML]); const expectedResult1 = `import asyncio - +import re from playwright.async_api import Playwright, async_playwright, expect diff --git a/tests/library/inspector/cli-codegen-python.spec.ts b/tests/library/inspector/cli-codegen-python.spec.ts index 5daa340a28..2bccbf3d93 100644 --- a/tests/library/inspector/cli-codegen-python.spec.ts +++ b/tests/library/inspector/cli-codegen-python.spec.ts @@ -25,7 +25,8 @@ const launchOptions = (channel: string) => { test('should print the correct imports and context options', async ({ runCLI, channel, browserName }) => { const cli = runCLI(['--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -36,7 +37,8 @@ def run(playwright: Playwright) -> None: test('should print the correct context options for custom settings', async ({ runCLI, channel, browserName }) => { const cli = runCLI(['--color-scheme=light', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -49,7 +51,8 @@ test('should print the correct context options when using a device', async ({ br test.skip(browserName !== 'chromium'); const cli = runCLI(['--device=Pixel 2', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -62,7 +65,8 @@ test('should print the correct context options when using a device and additiona test.skip(browserName !== 'webkit'); const cli = runCLI(['--color-scheme=light', '--device=iPhone 11', '--target=python', emptyHTML]); - const expectedResult = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -78,7 +82,8 @@ test('should save the codegen output to a file if specified', async ({ runCLI, c }); await cli.waitForCleanExit(); const content = fs.readFileSync(tmpFile); - expect(content.toString()).toBe(`from playwright.sync_api import Playwright, sync_playwright, expect + expect(content.toString()).toBe(`import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: @@ -103,7 +108,8 @@ test('should print load/save storage_state', async ({ runCLI, channel, browserNa const saveFileName = testInfo.outputPath('save.json'); await fs.promises.writeFile(loadFileName, JSON.stringify({ cookies: [], origins: [] }), 'utf8'); const cli = runCLI([`--load-storage=${loadFileName}`, `--save-storage=${saveFileName}`, '--target=python', emptyHTML]); - const expectedResult1 = `from playwright.sync_api import Playwright, sync_playwright, expect + const expectedResult1 = `import re +from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None: From 9d40f619c4b683e961b49a677a63e207d3d66e76 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 20 Mar 2024 16:48:00 -0700 Subject: [PATCH 131/141] feat: deprecate "devtools" launch option (#30025) References #29899. --- docs/src/api/params.md | 1 + packages/playwright-core/types/types.d.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index b28104814e..e3b2894c3c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -999,6 +999,7 @@ disable timeout. If specified, traces are saved into this directory. ## browser-option-devtools +* deprecated: Use [debugging tools](../debug.md) instead. - `devtools` <[boolean]> **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index b7be2ff91e..235de894cd 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13172,6 +13172,7 @@ export interface BrowserType { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; @@ -13576,6 +13577,7 @@ export interface BrowserType { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; @@ -20270,6 +20272,7 @@ export interface LaunchOptions { /** * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. */ devtools?: boolean; From 6f360f720727c2517e6aaeb7f0573ba8c6a894f4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 20 Mar 2024 21:01:17 -0700 Subject: [PATCH 132/141] feat(trace): do not record events that are not used in the viewer (#30030) This is especially useful for network events that are already in the har, but also get into the trace. --- .../src/server/dispatchers/dispatcher.ts | 33 +++++-------------- .../src/server/instrumentation.ts | 3 -- .../src/server/trace/recorder/tracing.ts | 26 ++++++++------- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 0b250fe76e..44d68c73a2 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -24,7 +24,6 @@ import { SdkObject } from '../instrumentation'; import type { PlaywrightDispatcher } from './playwrightDispatcher'; import { eventsHelper } from '../..//utils/eventsHelper'; import type { RegisteredListener } from '../..//utils/eventsHelper'; -import type * as trace from '@trace/trace'; import { isProtocolError } from '../protocolError'; export const dispatcherSymbol = Symbol('dispatcher'); @@ -81,7 +80,7 @@ export class Dispatcher(); @@ -63,7 +62,6 @@ export interface Instrumentation { onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent): void; onPageOpen(page: Page): void; onPageClose(page: Page): void; onBrowserOpen(browser: Browser): void; @@ -75,7 +73,6 @@ export interface InstrumentationListener { onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onCallLog?(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onEvent?(sdkObject: SdkObject, event: trace.EventTraceEvent): void; onPageOpen?(page: Page): void; onPageClose?(page: Page): void; onBrowserOpen?(browser: Browser): void; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 52fe777134..774cd24163 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -43,6 +43,7 @@ import { Snapshotter } from './snapshotter'; import { yazl } from '../../../zipBundle'; import type { ConsoleMessage } from '../../console'; import { Dispatcher } from '../../dispatchers/dispatcher'; +import { serializeError } from '../../errors'; const version: trace.VERSION = 6; @@ -182,6 +183,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._context.instrumentation.addListener(this, this._context); this._eventListeners.push( eventsHelper.addEventListener(this._context, BrowserContext.Events.Console, this._onConsoleMessage.bind(this)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.PageError, this._onPageError.bind(this)), ); if (this._state.options.screenshots) this._startScreencast(); @@ -396,18 +398,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata); } - onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) { - if (!sdkObject.attribution.context) - return; - if (event.method === 'console' || - (event.method === '__create__' && event.class === 'ConsoleMessage') || - (event.method === '__create__' && event.class === 'JSHandle')) { - // Console messages are handled separately. - return; - } - this._appendTraceEvent(event); - } - onEntryStarted(entry: har.Entry) { this._pendingHarEntries.add(entry); } @@ -456,6 +446,18 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._appendTraceEvent(event); } + private _onPageError(error: Error, page: Page) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'pageError', + params: { error: serializeError(error) }, + pageId: page.guid, + }; + this._appendTraceEvent(event); + } + private _startScreencastInPage(page: Page) { page.setScreencastOptions(kScreencastOptions); const prefix = page.guid; From 3e73a6ce697240c06b087f9d066b11d2bc97b4b1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 20 Mar 2024 21:01:30 -0700 Subject: [PATCH 133/141] feat(test runner): show multiple errors, at most one per stage (#30024) Previously, there was at most one "hard error", as opposite to multiple "soft errors". This was done to preserve the historic behavior at the time of introducing multiple `TestInfo.errors`. With this change, every user callback that is executed `withRunnable()` can throw an error and/or timeout, and both of these will end up in `TestInfo.errors`. Additionally, there is at most one "unhandled exception" error, to avoid flooding the report with mass failures. Drive-by: remove boolean arguments from `_failWithError()`. Fixes #29876. --- packages/playwright/src/matchers/expect.ts | 5 ++- .../src/matchers/toMatchSnapshot.ts | 3 +- packages/playwright/src/worker/testInfo.ts | 40 ++++++------------- packages/playwright/src/worker/workerMain.ts | 18 +++++---- tests/playwright-test/fixture-errors.spec.ts | 21 ++++++++++ 5 files changed, 50 insertions(+), 37 deletions(-) diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0b6b3da8ad..55f121474f 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -267,7 +267,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { params: args[0] ? { expected: args[0] } : undefined, wallTime, infectParentStepsWithError: this._info.isSoft, - isSoft: this._info.isSoft, }; const step = testInfo._addStep(stepInfo); @@ -275,7 +274,9 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const reportStepError = (jestError: ExpectError) => { const error = new ExpectError(jestError, customMessage, stackFrames); step.complete({ error }); - if (!this._info.isSoft) + if (this._info.isSoft) + testInfo._failWithError(error); + else throw error; }; diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index f6e5859d4f..442483a0de 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -232,7 +232,8 @@ class SnapshotHelper { return this.createMatcherResult(message, true); } if (this.updateSnapshots === 'missing') { - this.testInfo._failWithError(new Error(message), false /* isHardError */, false /* retriable */); + this.testInfo._hasNonRetriableError = true; + this.testInfo._failWithError(new Error(message)); return this.createMatcherResult('', true); } return this.createMatcherResult(message, false); diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 315b42665c..3e0dab8b95 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -44,7 +44,6 @@ export interface TestStepInternal { error?: TestInfoError; infectParentStepsWithError?: boolean; box?: boolean; - isSoft?: boolean; isStage?: boolean; } @@ -62,7 +61,6 @@ export class TestInfoImpl implements TestInfo { readonly _timeoutManager: TimeoutManager; readonly _startTime: number; readonly _startWallTime: number; - private _hasHardError: boolean = false; readonly _tracing: TestTracing; _wasInterrupted = false; @@ -74,6 +72,7 @@ export class TestInfoImpl implements TestInfo { _onDidFinishTestFunction: (() => Promise) | undefined; private readonly _stages: TestStage[] = []; _hasNonRetriableError = false; + _hasUnhandledError = false; _allowSkips = false; // ------------ TestInfo fields ------------ @@ -314,9 +313,6 @@ export class TestInfoImpl implements TestInfo { this._onStepEnd(payload); const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined; this._tracing.appendAfterActionForStep(stepId, errorForTrace, result.attachments); - - if (step.isSoft && result.error) - this._failWithError(result.error, false /* isHardError */, true /* retriable */); } }; const parentStepList = parentStep ? parentStep.steps : this._steps; @@ -344,17 +340,7 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } - _failWithError(error: Error, isHardError: boolean, retriable: boolean) { - if (!retriable) - this._hasNonRetriableError = true; - // Do not overwrite any previous hard errors. - // Some (but not all) scenarios include: - // - expect() that fails after uncaught exception. - // - fail in fixture teardown after the test failure. - if (isHardError && this._hasHardError) - return; - if (isHardError) - this._hasHardError = true; + _failWithError(error: Error) { if (this.status === 'passed' || this.status === 'skipped') this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; const serialized = serializeError(error); @@ -378,24 +364,31 @@ export class TestInfoImpl implements TestInfo { try { await cb(); } catch (e) { + // Only handle errors directly thrown by the user code. + if (!stage.runnable) + throw e; if (this._allowSkips && (e instanceof SkipError)) { if (this.status === 'passed') this.status = 'skipped'; - } else if (!(e instanceof TimeoutManagerError)) { - // Note: we handle timeout errors at the top level, so ignore them here. - // Unfortunately, we cannot ignore user errors here. Consider the following scenario: + } else { + // Unfortunately, we have to handle user errors and timeout errors differently. + // Consider the following scenario: // - locator.click times out // - all stages containing the test function finish with TimeoutManagerError // - test finishes, the page is closed and this triggers locator.click error // - we would like to present the locator.click error to the user // - therefore, we need a try/catch inside the "run with timeout" block and capture the error - this._failWithError(e, true /* isHardError */, true /* retriable */); + this._failWithError(e); } throw e; } }); stage.step?.complete({}); } catch (error) { + // When interrupting, we arrive here with a TimeoutManagerError, but we should not + // consider it a timeout. + if (!this._wasInterrupted && (error instanceof TimeoutManagerError) && stage.runnable) + this._failWithError(error); stage.step?.complete({ error }); throw error; } finally { @@ -406,13 +399,6 @@ export class TestInfoImpl implements TestInfo { } } - _handlePossibleTimeoutError(error: Error) { - // When interrupting, we arrive here with a TimeoutManagerError, but we should not - // consider it a timeout. - if (!this._wasInterrupted && (error instanceof TimeoutManagerError)) - this._failWithError(error, false /* isHardError */, true /* retriable */); - } - _isFailure() { return this.status !== 'skipped' && this.status !== this.expectedStatus; } diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 44a2c451f2..20c7feca6a 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -147,8 +147,9 @@ export class WorkerMain extends ProcessRunner { private async _teardownScopes() { const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); const runnable = { type: 'teardown' } as const; - await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(error => fakeTestInfo._handlePossibleTimeoutError(error)); - await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(error => fakeTestInfo._handlePossibleTimeoutError(error)); + // Ignore top-level errors, they are already inside TestInfo.errors. + await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); + await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); this._fatalErrors.push(...fakeTestInfo.errors); } @@ -165,7 +166,10 @@ export class WorkerMain extends ProcessRunner { // and unhandled errors - both lead to the test failing. This is good for regular tests, // so that you can, e.g. expect() from inside an event handler. The test fails, // and we restart the worker. - this._currentTest._failWithError(error, true /* isHardError */, true /* retriable */); + if (!this._currentTest._hasUnhandledError) { + this._currentTest._hasUnhandledError = true; + this._currentTest._failWithError(error); + } // For tests marked with test.fail(), this might be a problem when unhandled error // is not coming from the user test code (legit failure), but from fixtures or test runner. @@ -355,7 +359,7 @@ export class WorkerMain extends ProcessRunner { const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); }); - }).catch(error => testInfo._handlePossibleTimeoutError(error)); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; @@ -416,7 +420,7 @@ export class WorkerMain extends ProcessRunner { } if (firstAfterHooksError) throw firstAfterHooksError; - }).catch(error => testInfo._handlePossibleTimeoutError(error)); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. if (testInfo._isFailure()) this._isStopped = true; @@ -455,13 +459,13 @@ export class WorkerMain extends ProcessRunner { if (firstWorkerCleanupError) throw firstWorkerCleanupError; - }).catch(error => testInfo._handlePossibleTimeoutError(error)); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. } const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { await testInfo._tracing.stopIfNeeded(); - }).catch(error => testInfo._handlePossibleTimeoutError(error)); + }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. testInfo.duration = (testInfo._timeoutManager.defaultSlot().elapsed + afterHooksSlot.elapsed) | 0; diff --git a/tests/playwright-test/fixture-errors.spec.ts b/tests/playwright-test/fixture-errors.spec.ts index a58814104f..ae323c287a 100644 --- a/tests/playwright-test/fixture-errors.spec.ts +++ b/tests/playwright-test/fixture-errors.spec.ts @@ -733,3 +733,24 @@ test('should not continue with scope teardown after fixture teardown timeout', a expect(result.output).toContain('Test finished within timeout of 1000ms, but tearing down "fixture2" ran out of time.'); expect(result.output).not.toContain('in fixture teardown'); }); + +test('should report fixture teardown error after test error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test as base, expect } from '@playwright/test'; + const test = base.extend({ + foo: async ({ }, use) => { + await use(); + throw new Error('Error from the fixture foo'); + }, + }); + test('fails', async ({ foo }) => { + throw new Error('Error from the test'); + }); + `, + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Error from the fixture foo'); + expect(result.output).toContain('Error from the test'); +}); From 7ad0a12c23ffc898f7f27150a62ac979692049bb Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 20 Mar 2024 21:09:49 -0700 Subject: [PATCH 134/141] chore: remove ui mode update globals (#30031) --- .../trace-viewer/src/ui/teleSuiteUpdater.ts | 33 +-- packages/trace-viewer/src/ui/uiModeModel.ts | 12 +- .../src/ui/uiModeTestListView.tsx | 4 +- packages/trace-viewer/src/ui/uiModeView.tsx | 223 +++++++++--------- 4 files changed, 147 insertions(+), 125 deletions(-) diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts index 582567e11a..253ad61a13 100644 --- a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -14,20 +14,14 @@ * limitations under the License. */ -import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver'; +import { TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; import { statusEx } from '@testIsomorphic/testTree'; import type { ReporterV2 } from 'playwright/src/reporters/reporterV2'; import type * as reporterTypes from 'playwright/types/testReporter'; - -export type Progress = { - total: number; - passed: number; - failed: number; - skipped: number; -}; +import type { Progress, TestModel } from './uiModeModel'; export type TeleSuiteUpdaterOptions = { - onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void, + onUpdate: (force?: boolean) => void, onError?: (error: reporterTypes.TestError) => void; pathSeparator: string; }; @@ -87,16 +81,16 @@ export class TeleSuiteUpdater { this.progress.passed = 0; this.progress.failed = 0; this.progress.skipped = 0; - this._options.onUpdate(this, true); + this._options.onUpdate(true); }, onEnd: () => { - this._options.onUpdate(this, true); + this._options.onUpdate(true); }, onTestBegin: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { (testResult as any)[statusEx] = 'running'; - this._options.onUpdate(this); + this._options.onUpdate(); }, onTestEnd: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { @@ -107,13 +101,13 @@ export class TeleSuiteUpdater { else ++this.progress.passed; (testResult as any)[statusEx] = testResult.status; - this._options.onUpdate(this); + this._options.onUpdate(); }, onError: (error: reporterTypes.TestError) => { this.loadErrors.push(error); this._options.onError?.(error); - this._options.onUpdate(this); + this._options.onUpdate(); }, printsToStdio: () => { @@ -134,10 +128,19 @@ export class TeleSuiteUpdater { this._receiver.dispatch(message); } - processTestReport(message: any) { + processTestReportEvent(message: any) { // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` // before we use it. this._lastRunReceiver?.dispatch(message)?.catch(() => {}); this._receiver.dispatch(message)?.catch(() => {}); } + + asModel(): TestModel { + return { + rootSuite: this.rootSuite || new TeleSuite('', 'root'), + config: this.config!, + loadErrors: this.loadErrors, + progress: this.progress, + }; + } } diff --git a/packages/trace-viewer/src/ui/uiModeModel.ts b/packages/trace-viewer/src/ui/uiModeModel.ts index f2656901d7..97952cfbfd 100644 --- a/packages/trace-viewer/src/ui/uiModeModel.ts +++ b/packages/trace-viewer/src/ui/uiModeModel.ts @@ -16,10 +16,18 @@ import type * as reporterTypes from 'playwright/types/testReporter'; +export type Progress = { + total: number; + passed: number; + failed: number; + skipped: number; +}; + export type TestModel = { - config: reporterTypes.FullConfig | undefined; - rootSuite: reporterTypes.Suite | undefined; + config: reporterTypes.FullConfig; + rootSuite: reporterTypes.Suite; loadErrors: reporterTypes.TestError[]; + progress: Progress; }; export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx index 422341f364..7ca7f95bdc 100644 --- a/packages/trace-viewer/src/ui/uiModeTestListView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -37,7 +37,7 @@ export const TestListView: React.FC<{ filterText: string, testTree: TestTree, testServerConnection: TestServerConnection | undefined, - testModel: TestModel, + testModel?: TestModel, runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean }, watchAll: boolean, @@ -86,6 +86,8 @@ export const TestListView: React.FC<{ // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { + if (!testModel) + return { selectedTreeItem: undefined }; const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; let testFile: SourceLocation | undefined; if (selectedTreeItem) { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index eb13d868ba..58e34a39e6 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -17,9 +17,9 @@ import '@web/third_party/vscode/codicon.css'; import '@web/common.css'; import React from 'react'; -import { baseFullConfig, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { TeleSuite } from '@testIsomorphic/teleReceiver'; import { TeleSuiteUpdater } from './teleSuiteUpdater'; -import type { Progress } from './teleSuiteUpdater'; +import type { Progress } from './uiModeModel'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; @@ -33,7 +33,6 @@ import { toggleTheme } from '@web/theme'; import { settings, useSetting } from '@web/uiUtils'; import { statusEx, TestTree } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree'; -import type { Disposable } from '@testIsomorphic/events'; import { TestServerConnection } from '@testIsomorphic/testServerConnection'; import { pathSeparator } from './uiModeModel'; import type { TestModel } from './uiModeModel'; @@ -41,10 +40,7 @@ import { FiltersView } from './uiModeFiltersView'; import { TestListView } from './uiModeTestListView'; import { TraceView } from './uiModeTraceView'; -let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {}; -// let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; - const xtermDataSource: XtermDataSource = { pending: [], clear: () => {}, @@ -62,7 +58,7 @@ export const UIModeView: React.FC<{}> = ({ ['skipped', false], ])); const [projectFilters, setProjectFilters] = React.useState>(new Map()); - const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined, loadErrors: [] }); + const [testModel, setTestModel] = React.useState(); const [progress, setProgress] = React.useState(); const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }>({}); const [visibleTestIds, setVisibleTestIds] = React.useState>(new Set()); @@ -83,62 +79,132 @@ export const UIModeView: React.FC<{}> = ({ const guid = new URLSearchParams(window.location.search).get('ws'); const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); - const connection = new TestServerConnection(wsURL.toString()); - setTestServerConnection(connection); - setIsLoading(true); - setWatchedTreeIds({ value: new Set() }); - updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined); - (async () => { - const status = await connection.runGlobalSetup(); - if (status === 'passed') - await refreshRootSuite(connection); - setIsLoading(false); - const { hasBrowsers } = await connection.checkBrowsers(); - setHasBrowsers(hasBrowsers); - })(); + setTestServerConnection(new TestServerConnection(wsURL.toString())); }, []); - React.useEffect(() => { - if (!testServerConnection) - return; - const disposables = [ - ...wireConnectionListeners(testServerConnection), - testServerConnection.onClose(() => setIsDisconnected(true)) - ]; - return () => { - for (const disposable of disposables) - disposable.dispose(); - }; - }, [testServerConnection]); - + // Load tests on startup. React.useEffect(() => { inputRef.current?.focus(); setIsLoading(true); reloadTests(); }, [reloadTests]); - updateRootSuite = React.useCallback((config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], newProgress: Progress | undefined) => { + // Wire server connection to the auxiliary UI features. + React.useEffect(() => { + if (!testServerConnection) + return; + const disposables = [ + testServerConnection.onStdio(params => { + if (params.buffer) { + const data = atob(params.buffer); + xtermDataSource.write(data); + } else { + xtermDataSource.write(params.text!); + } + }), + testServerConnection.onClose(() => setIsDisconnected(true)) + ]; + xtermDataSource.resize = (cols, rows) => { + xtermSize = { cols, rows }; + testServerConnection.resizeTerminalNoReply({ cols, rows }); + }; + return () => { + for (const disposable of disposables) + disposable.dispose(); + }; + }, [testServerConnection]); + + + // This is the main routine, every time connection updates it starts the + // whole workflow. + React.useEffect(() => { + if (!testServerConnection) + return; + + let throttleTimer: NodeJS.Timeout | undefined; + const teleSuiteUpdater = new TeleSuiteUpdater({ + onUpdate: immediate => { + clearTimeout(throttleTimer); + throttleTimer = undefined; + if (immediate) { + setTestModel(teleSuiteUpdater.asModel()); + } else if (!throttleTimer) { + throttleTimer = setTimeout(() => { + setTestModel(teleSuiteUpdater.asModel()); + }, 250); + } + }, + onError: error => { + xtermDataSource.write((error.stack || error.value || '') + '\n'); + }, + pathSeparator, + }); + + const updateList = async () => { + setIsLoading(true); + const result = await testServerConnection.listTests({}); + teleSuiteUpdater.processListReport(result.report); + setIsLoading(false); + }; + + setTestModel(undefined); + setIsLoading(true); + setWatchedTreeIds({ value: new Set() }); + (async () => { + const status = await testServerConnection.runGlobalSetup(); + if (status !== 'passed') + return; + const result = await testServerConnection.listTests({}); + teleSuiteUpdater.processListReport(result.report); + + testServerConnection.onListChanged(updateList); + testServerConnection.onReport(params => { + teleSuiteUpdater.processTestReportEvent(params); + }); + setIsLoading(false); + + const { hasBrowsers } = await testServerConnection.checkBrowsers(); + setHasBrowsers(hasBrowsers); + })(); + return () => { + clearTimeout(throttleTimer); + }; + }, [testServerConnection]); + + // Update project filter default values. + React.useEffect(() => { + if (!testModel) + return; + + const { config, rootSuite } = testModel; const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; - for (const projectName of projectFilters.keys()) { + const newFilter = new Map(projectFilters); + for (const projectName of newFilter.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) - projectFilters.delete(projectName); + newFilter.delete(projectName); } for (const projectSuite of rootSuite.suites) { - if (!projectFilters.has(projectSuite.title)) - projectFilters.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title)); + if (!newFilter.has(projectSuite.title)) + newFilter.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title)); } - if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true)) - projectFilters.set(projectFilters.entries().next().value[0], true); + if (!selectedProjects && newFilter.size && ![...newFilter.values()].includes(true)) + newFilter.set(newFilter.entries().next().value[0], true); + if (projectFilters.size !== newFilter.size || [...projectFilters].some(([k, v]) => newFilter.get(k) !== v)) + setProjectFilters(newFilter); + }, [projectFilters, testModel]); - setTestModel({ config, rootSuite, loadErrors }); - setProjectFilters(new Map(projectFilters)); - if (runningState && newProgress) - setProgress(newProgress); - else if (!newProgress) + // Update progress. + React.useEffect(() => { + if (runningState && testModel?.progress) + setProgress(testModel.progress); + else if (!testModel) setProgress(undefined); - }, [projectFilters, runningState]); + }, [testModel, runningState]); + // Test tree is built from the model and filters. const { testTree } = React.useMemo(() => { + if (!testModel) + return { testTree: new TestTree('', new TeleSuite('', 'root'), [], projectFilters, pathSeparator) }; const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); testTree.filterTree(filterText, statusFilters, runningState?.testIds); testTree.sortAndPropagateStatus(); @@ -149,7 +215,7 @@ export const UIModeView: React.FC<{}> = ({ }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set) => { - if (!testServerConnection) + if (!testServerConnection || !testModel) return; if (mode === 'bounce-if-busy' && runningState) return; @@ -189,6 +255,7 @@ export const UIModeView: React.FC<{}> = ({ }); }, [projectFilters, runningState, testModel, testServerConnection]); + // Watch implementation. React.useEffect(() => { if (!testServerConnection) return; @@ -217,6 +284,7 @@ export const UIModeView: React.FC<{}> = ({ return () => disposable.dispose(); }, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); + // Shortcuts. React.useEffect(() => { if (!testServerConnection) return; @@ -229,9 +297,7 @@ export const UIModeView: React.FC<{}> = ({ reloadTests(); } }; - addEventListener('keydown', onShortcutEvent); - return () => { removeEventListener('keydown', onShortcutEvent); }; @@ -287,7 +353,7 @@ export const UIModeView: React.FC<{}> = ({
- +
@@ -343,60 +409,3 @@ export const UIModeView: React.FC<{}> = ({
; }; - -let teleSuiteUpdater: TeleSuiteUpdater | undefined; - -let throttleTimer: NodeJS.Timeout | undefined; -let throttleData: { config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress } | undefined; -const throttledAction = () => { - clearTimeout(throttleTimer); - throttleTimer = undefined; - updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress); -}; - -const throttleUpdateRootSuite = (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress, immediate = false) => { - throttleData = { config, rootSuite, loadErrors, progress }; - if (immediate) - throttledAction(); - else if (!throttleTimer) - throttleTimer = setTimeout(throttledAction, 250); -}; - -const refreshRootSuite = async (testServerConnection: TestServerConnection): Promise => { - teleSuiteUpdater = new TeleSuiteUpdater({ - onUpdate: (source, immediate) => { - throttleUpdateRootSuite(source.config!, source.rootSuite || new TeleSuite('', 'root'), source.loadErrors, source.progress, immediate); - }, - onError: error => { - xtermDataSource.write((error.stack || error.value || '') + '\n'); - }, - pathSeparator, - }); - const { report } = await testServerConnection.listTests({}); - teleSuiteUpdater?.processListReport(report); -}; - -const wireConnectionListeners = (testServerConnection: TestServerConnection): Disposable[] => { - const disposables: Disposable[] = [ - testServerConnection.onListChanged(async () => { - const { report } = await testServerConnection.listTests({}); - teleSuiteUpdater?.processListReport(report); - }), - testServerConnection.onStdio(params => { - if (params.buffer) { - const data = atob(params.buffer); - xtermDataSource.write(data); - } else { - xtermDataSource.write(params.text!); - } - }), - testServerConnection.onReport(params => { - teleSuiteUpdater?.processTestReport(params); - }), - ]; - xtermDataSource.resize = (cols, rows) => { - xtermSize = { cols, rows }; - testServerConnection.resizeTerminalNoReply({ cols, rows }); - }; - return disposables; -}; From 3d4e0061bc30912199f9b09e34b3580830d3cd65 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 21 Mar 2024 13:39:24 +0100 Subject: [PATCH 135/141] devops: fix roll_browser script Signed-off-by: Max Schmitt --- utils/roll_browser.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/utils/roll_browser.js b/utils/roll_browser.js index a72a48eab6..ba5c41b379 100755 --- a/utils/roll_browser.js +++ b/utils/roll_browser.js @@ -61,8 +61,6 @@ Example: 'wk': 'webkit', }[args[0].toLowerCase()] ?? args[0].toLowerCase(); const descriptors = [browsersJSON.browsers.find(b => b.name === browserName)]; - if (browserName === 'chromium') - descriptors.push(browsersJSON.browsers.find(b => b.name === 'chromium-with-symbols')); if (browserName === 'firefox') descriptors.push(browsersJSON.browsers.find(b => b.name === 'firefox-asan')); From 86d43131e97a3152326cf5741db8731cf803a591 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 21 Mar 2024 06:23:38 -0700 Subject: [PATCH 136/141] feat(chromium-tip-of-tree): roll to r1204 (#30038) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5a4f6caadb..d4670327d7 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1203", + "revision": "1204", "installByDefault": false, - "browserVersion": "124.0.6367.0" + "browserVersion": "125.0.6370.0" }, { "name": "firefox", From 352cb7fa08deaad87a594811f24306e509f83061 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 21 Mar 2024 14:26:42 +0100 Subject: [PATCH 137/141] feat(browser): roll Chromium to 1110 (#30039) Browser roll bot was broken, got fixed by https://github.com/microsoft/playwright/commit/3d4e0061bc30912199f9b09e34b3580830d3cd65. --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/chromium/protocol.d.ts | 146 +++++++++++++----- .../src/server/deviceDescriptorsSource.json | 96 ++++++------ packages/playwright-core/types/protocol.d.ts | 146 +++++++++++++----- 5 files changed, 266 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index b8f66a4c18..93be49146e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-123.0.6312.22-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-124.0.6367.8-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-123.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-17.4-blue.svg?logo=safari)](https://webkit.org/) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 123.0.6312.22 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 124.0.6367.8 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 17.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 123.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index d4670327d7..6f31974b96 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1107", + "revision": "1110", "installByDefault": true, - "browserVersion": "123.0.6312.22" + "browserVersion": "124.0.6367.8" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/chromium/protocol.d.ts b/packages/playwright-core/src/server/chromium/protocol.d.ts index 83b8d235ce..0ea475d12e 100644 --- a/packages/playwright-core/src/server/chromium/protocol.d.ts +++ b/packages/playwright-core/src/server/chromium/protocol.d.ts @@ -831,7 +831,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; /** * Details for issues around "Attribution Reporting API" usage. Explainer: https://github.com/WICG/attribution-reporting-api @@ -2493,6 +2493,28 @@ stylesheet rules) this rule came from. */ tryRules: CSSTryRule[]; } + /** + * CSS @position-try rule representation. + */ + export interface CSSPositionTryRule { + /** + * The prelude dashed-ident name + */ + name: Value; + /** + * The css style sheet identifier (absent for user agent stylesheet and user-specified +stylesheet rules) this rule came from. + */ + styleSheetId?: StyleSheetId; + /** + * Parent stylesheet's origin. + */ + origin: StyleSheetOrigin; + /** + * Associated style declaration. + */ + style: CSSStyle; + } /** * CSS keyframes rule representation. */ @@ -2820,6 +2842,10 @@ attributes) for a DOM node identified by `nodeId`. * A list of CSS position fallbacks matching this node. */ cssPositionFallbackRules?: CSSPositionFallbackRule[]; + /** + * A list of CSS @position-try rules matching this node, based on the position-try-options property. + */ + cssPositionTryRules?: CSSPositionTryRule[]; /** * A list of CSS at-property rules matching this node. */ @@ -2882,6 +2908,17 @@ the full layer tree for the tree scope and their ordering. export type getLayersForNodeReturnValue = { rootLayer: CSSLayerData; } + /** + * Given a CSS selector text and a style sheet ID, getLocationForSelector +returns an array of locations of the CSS selector in the style sheet. + */ + export type getLocationForSelectorParameters = { + styleSheetId: StyleSheetId; + selectorText: string; + } + export type getLocationForSelectorReturnValue = { + ranges: SourceRange[]; + } /** * Starts tracking the given computed styles for updates. The specified array of properties replaces the one previously specified. Pass empty array to disable tracking. @@ -8052,6 +8089,7 @@ milliseconds relatively to this requestTime. headers: Headers; /** * HTTP POST request data. +Use postDataEntries instead. */ postData?: string; /** @@ -8059,7 +8097,7 @@ milliseconds relatively to this requestTime. */ hasPostData?: boolean; /** - * Request body elements. This will be converted from base64 to binary + * Request body elements (post data broken into individual entries). */ postDataEntries?: PostDataEntry[]; /** @@ -9787,6 +9825,18 @@ matches provided URL. * Connection type if known. */ connectionType?: ConnectionType; + /** + * WebRTC packet loss (percent, 0-100). 0 disables packet loss emulation, 100 drops all the packets. + */ + packetLoss?: number; + /** + * WebRTC packet queue length (packet). 0 removes any queue length limitations. + */ + packetQueueLength?: number; + /** + * WebRTC packetReordering feature. + */ + packetReordering?: boolean; } export type emulateNetworkConditionsReturnValue = { } @@ -11065,7 +11115,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factor"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -11534,7 +11584,7 @@ Example URLs: http://www.google.com/file.html -> "google.com" /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; /** * Types of not restored reasons for back-forward cache. */ @@ -11811,7 +11861,7 @@ open. */ type: DialogType; /** - * True if browser is capable showing or acting on the given dialog. When browser has no + * True iff browser is capable showing or acting on the given dialog. When browser has no dialog handler for given target, calling alert while Page domain is engaged will stall the page execution. Execution can be resumed via calling Page.handleJavaScriptDialog. */ @@ -13533,34 +13583,10 @@ Tokens from that issuer. * Enum of network fetches auctions can do. */ export type InterestGroupAuctionFetchType = "bidderJs"|"bidderWasm"|"sellerJs"|"bidderTrustedSignals"|"sellerTrustedSignals"; - /** - * Ad advertising element inside an interest group. - */ - export interface InterestGroupAd { - renderURL: string; - metadata?: string; - } - /** - * The full details of an interest group. - */ - export interface InterestGroupDetails { - ownerOrigin: string; - name: string; - expirationTime: Network.TimeSinceEpoch; - joiningOrigin: string; - biddingLogicURL?: string; - biddingWasmHelperURL?: string; - updateURL?: string; - trustedBiddingSignalsURL?: string; - trustedBiddingSignalsKeys: string[]; - userBiddingSignals?: string; - ads: InterestGroupAd[]; - adComponents: InterestGroupAd[]; - } /** * Enum of shared storage access types. */ - export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"; + export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"documentGet"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"|"headerSet"|"headerAppend"|"headerDelete"|"headerClear"; /** * Struct for a single key-value pair in an origin's shared storage. */ @@ -13644,22 +13670,28 @@ SharedStorageAccessType.documentAppend, SharedStorageAccessType.documentDelete, SharedStorageAccessType.workletSet, SharedStorageAccessType.workletAppend, -SharedStorageAccessType.workletDelete, and -SharedStorageAccessType.workletGet. +SharedStorageAccessType.workletDelete, +SharedStorageAccessType.workletGet, +SharedStorageAccessType.headerSet, +SharedStorageAccessType.headerAppend, and +SharedStorageAccessType.headerDelete. */ key?: string; /** * Value for a specific entry in an origin's shared storage. Present only for SharedStorageAccessType.documentSet, SharedStorageAccessType.documentAppend, -SharedStorageAccessType.workletSet, and -SharedStorageAccessType.workletAppend. +SharedStorageAccessType.workletSet, +SharedStorageAccessType.workletAppend, +SharedStorageAccessType.headerSet, and +SharedStorageAccessType.headerAppend. */ value?: string; /** * Whether or not to set an entry for a key if that key is already present. -Present only for SharedStorageAccessType.documentSet and -SharedStorageAccessType.workletSet. +Present only for SharedStorageAccessType.documentSet, +SharedStorageAccessType.workletSet, and +SharedStorageAccessType.headerSet. */ ignoreIfPresent?: boolean; } @@ -13789,6 +13821,23 @@ int } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; + /** + * A single Related Website Set object. + */ + export interface RelatedWebsiteSet { + /** + * The primary site of this set, along with the ccTLDs if there is any. + */ + primarySites: string[]; + /** + * The associated sites of this set, along with the ccTLDs if there is any. + */ + associatedSites: string[]; + /** + * The service sites of this set, along with the ccTLDs if there is any. + */ + serviceSites: string[]; + } /** * A cache's contents have been modified. @@ -14216,7 +14265,13 @@ Leaves other stored data, including the issuer's Redemption Records, intact. name: string; } export type getInterestGroupDetailsReturnValue = { - details: InterestGroupDetails; + /** + * This largely corresponds to: +https://wicg.github.io/turtledove/#dictdef-generatebidinterestgroup +but has absolute expirationTime instead of relative lifetimeMs and +also adds joiningOrigin. + */ + details: { [key: string]: string }; } /** * Enables/Disables issuing of interestGroupAccessed events. @@ -14345,6 +14400,15 @@ interestGroupAuctionNetworkRequestCreated. } export type setAttributionReportingTrackingReturnValue = { } + /** + * Returns the effective Related Website Sets in use by this profile for the browser +session. The effective Related Website Sets will not change during a browser session. + */ + export type getRelatedWebsiteSetsParameters = { + } + export type getRelatedWebsiteSetsReturnValue = { + sets: RelatedWebsiteSet[]; + } } /** @@ -14582,10 +14646,10 @@ supported. export type SessionID = string; export interface TargetInfo { targetId: TargetID; - type: string; /** * List of types: https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22 */ + type: string; title: string; url: string; /** @@ -16385,7 +16449,7 @@ See also: requestId?: Network.RequestId; /** * Error information -`errorMessage` is null if `errorType` is null. +`errorMessage` is null iff `errorType` is null. */ errorType?: RuleSetErrorType; /** @@ -19515,6 +19579,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeParameters; "CSS.getStyleSheetText": CSS.getStyleSheetTextParameters; "CSS.getLayersForNode": CSS.getLayersForNodeParameters; + "CSS.getLocationForSelector": CSS.getLocationForSelectorParameters; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesParameters; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesParameters; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeParameters; @@ -19885,6 +19950,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsParameters; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeParameters; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingParameters; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsParameters; "SystemInfo.getInfo": SystemInfo.getInfoParameters; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateParameters; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoParameters; @@ -20097,6 +20163,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeReturnValue; "CSS.getStyleSheetText": CSS.getStyleSheetTextReturnValue; "CSS.getLayersForNode": CSS.getLayersForNodeReturnValue; + "CSS.getLocationForSelector": CSS.getLocationForSelectorReturnValue; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesReturnValue; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesReturnValue; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeReturnValue; @@ -20467,6 +20534,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsReturnValue; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeReturnValue; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingReturnValue; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsReturnValue; "SystemInfo.getInfo": SystemInfo.getInfoReturnValue; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateReturnValue; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoReturnValue; diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index cd9193a3d6..49a3c9446e 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -978,7 +978,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -989,7 +989,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1000,7 +1000,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1011,7 +1011,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1022,7 +1022,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1033,7 +1033,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1044,7 +1044,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1055,7 +1055,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1066,7 +1066,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1077,7 +1077,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1088,7 +1088,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1099,7 +1099,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1110,7 +1110,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1121,7 +1121,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1132,7 +1132,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1143,7 +1143,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1154,7 +1154,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1165,7 +1165,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1176,7 +1176,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1187,7 +1187,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1242,7 +1242,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1253,7 +1253,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1264,7 +1264,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1275,7 +1275,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1286,7 +1286,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1297,7 +1297,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1308,7 +1308,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1319,7 +1319,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1330,7 +1330,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1345,7 +1345,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1360,7 +1360,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1375,7 +1375,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1390,7 +1390,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1405,7 +1405,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1420,7 +1420,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1431,7 +1431,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1442,7 +1442,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1457,7 +1457,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36 Edg/123.0.6312.22", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36 Edg/124.0.6367.8", "screen": { "width": 1792, "height": 1120 @@ -1502,7 +1502,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1517,7 +1517,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.22 Safari/537.36 Edg/123.0.6312.22", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.8 Safari/537.36 Edg/124.0.6367.8", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index 83b8d235ce..0ea475d12e 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -831,7 +831,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; /** * Details for issues around "Attribution Reporting API" usage. Explainer: https://github.com/WICG/attribution-reporting-api @@ -2493,6 +2493,28 @@ stylesheet rules) this rule came from. */ tryRules: CSSTryRule[]; } + /** + * CSS @position-try rule representation. + */ + export interface CSSPositionTryRule { + /** + * The prelude dashed-ident name + */ + name: Value; + /** + * The css style sheet identifier (absent for user agent stylesheet and user-specified +stylesheet rules) this rule came from. + */ + styleSheetId?: StyleSheetId; + /** + * Parent stylesheet's origin. + */ + origin: StyleSheetOrigin; + /** + * Associated style declaration. + */ + style: CSSStyle; + } /** * CSS keyframes rule representation. */ @@ -2820,6 +2842,10 @@ attributes) for a DOM node identified by `nodeId`. * A list of CSS position fallbacks matching this node. */ cssPositionFallbackRules?: CSSPositionFallbackRule[]; + /** + * A list of CSS @position-try rules matching this node, based on the position-try-options property. + */ + cssPositionTryRules?: CSSPositionTryRule[]; /** * A list of CSS at-property rules matching this node. */ @@ -2882,6 +2908,17 @@ the full layer tree for the tree scope and their ordering. export type getLayersForNodeReturnValue = { rootLayer: CSSLayerData; } + /** + * Given a CSS selector text and a style sheet ID, getLocationForSelector +returns an array of locations of the CSS selector in the style sheet. + */ + export type getLocationForSelectorParameters = { + styleSheetId: StyleSheetId; + selectorText: string; + } + export type getLocationForSelectorReturnValue = { + ranges: SourceRange[]; + } /** * Starts tracking the given computed styles for updates. The specified array of properties replaces the one previously specified. Pass empty array to disable tracking. @@ -8052,6 +8089,7 @@ milliseconds relatively to this requestTime. headers: Headers; /** * HTTP POST request data. +Use postDataEntries instead. */ postData?: string; /** @@ -8059,7 +8097,7 @@ milliseconds relatively to this requestTime. */ hasPostData?: boolean; /** - * Request body elements. This will be converted from base64 to binary + * Request body elements (post data broken into individual entries). */ postDataEntries?: PostDataEntry[]; /** @@ -9787,6 +9825,18 @@ matches provided URL. * Connection type if known. */ connectionType?: ConnectionType; + /** + * WebRTC packet loss (percent, 0-100). 0 disables packet loss emulation, 100 drops all the packets. + */ + packetLoss?: number; + /** + * WebRTC packet queue length (packet). 0 removes any queue length limitations. + */ + packetQueueLength?: number; + /** + * WebRTC packetReordering feature. + */ + packetReordering?: boolean; } export type emulateNetworkConditionsReturnValue = { } @@ -11065,7 +11115,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factor"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"window-placement"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -11534,7 +11584,7 @@ Example URLs: http://www.google.com/file.html -> "google.com" /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"Portal"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"; /** * Types of not restored reasons for back-forward cache. */ @@ -11811,7 +11861,7 @@ open. */ type: DialogType; /** - * True if browser is capable showing or acting on the given dialog. When browser has no + * True iff browser is capable showing or acting on the given dialog. When browser has no dialog handler for given target, calling alert while Page domain is engaged will stall the page execution. Execution can be resumed via calling Page.handleJavaScriptDialog. */ @@ -13533,34 +13583,10 @@ Tokens from that issuer. * Enum of network fetches auctions can do. */ export type InterestGroupAuctionFetchType = "bidderJs"|"bidderWasm"|"sellerJs"|"bidderTrustedSignals"|"sellerTrustedSignals"; - /** - * Ad advertising element inside an interest group. - */ - export interface InterestGroupAd { - renderURL: string; - metadata?: string; - } - /** - * The full details of an interest group. - */ - export interface InterestGroupDetails { - ownerOrigin: string; - name: string; - expirationTime: Network.TimeSinceEpoch; - joiningOrigin: string; - biddingLogicURL?: string; - biddingWasmHelperURL?: string; - updateURL?: string; - trustedBiddingSignalsURL?: string; - trustedBiddingSignalsKeys: string[]; - userBiddingSignals?: string; - ads: InterestGroupAd[]; - adComponents: InterestGroupAd[]; - } /** * Enum of shared storage access types. */ - export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"; + export type SharedStorageAccessType = "documentAddModule"|"documentSelectURL"|"documentRun"|"documentSet"|"documentAppend"|"documentDelete"|"documentClear"|"documentGet"|"workletSet"|"workletAppend"|"workletDelete"|"workletClear"|"workletGet"|"workletKeys"|"workletEntries"|"workletLength"|"workletRemainingBudget"|"headerSet"|"headerAppend"|"headerDelete"|"headerClear"; /** * Struct for a single key-value pair in an origin's shared storage. */ @@ -13644,22 +13670,28 @@ SharedStorageAccessType.documentAppend, SharedStorageAccessType.documentDelete, SharedStorageAccessType.workletSet, SharedStorageAccessType.workletAppend, -SharedStorageAccessType.workletDelete, and -SharedStorageAccessType.workletGet. +SharedStorageAccessType.workletDelete, +SharedStorageAccessType.workletGet, +SharedStorageAccessType.headerSet, +SharedStorageAccessType.headerAppend, and +SharedStorageAccessType.headerDelete. */ key?: string; /** * Value for a specific entry in an origin's shared storage. Present only for SharedStorageAccessType.documentSet, SharedStorageAccessType.documentAppend, -SharedStorageAccessType.workletSet, and -SharedStorageAccessType.workletAppend. +SharedStorageAccessType.workletSet, +SharedStorageAccessType.workletAppend, +SharedStorageAccessType.headerSet, and +SharedStorageAccessType.headerAppend. */ value?: string; /** * Whether or not to set an entry for a key if that key is already present. -Present only for SharedStorageAccessType.documentSet and -SharedStorageAccessType.workletSet. +Present only for SharedStorageAccessType.documentSet, +SharedStorageAccessType.workletSet, and +SharedStorageAccessType.headerSet. */ ignoreIfPresent?: boolean; } @@ -13789,6 +13821,23 @@ int } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; + /** + * A single Related Website Set object. + */ + export interface RelatedWebsiteSet { + /** + * The primary site of this set, along with the ccTLDs if there is any. + */ + primarySites: string[]; + /** + * The associated sites of this set, along with the ccTLDs if there is any. + */ + associatedSites: string[]; + /** + * The service sites of this set, along with the ccTLDs if there is any. + */ + serviceSites: string[]; + } /** * A cache's contents have been modified. @@ -14216,7 +14265,13 @@ Leaves other stored data, including the issuer's Redemption Records, intact. name: string; } export type getInterestGroupDetailsReturnValue = { - details: InterestGroupDetails; + /** + * This largely corresponds to: +https://wicg.github.io/turtledove/#dictdef-generatebidinterestgroup +but has absolute expirationTime instead of relative lifetimeMs and +also adds joiningOrigin. + */ + details: { [key: string]: string }; } /** * Enables/Disables issuing of interestGroupAccessed events. @@ -14345,6 +14400,15 @@ interestGroupAuctionNetworkRequestCreated. } export type setAttributionReportingTrackingReturnValue = { } + /** + * Returns the effective Related Website Sets in use by this profile for the browser +session. The effective Related Website Sets will not change during a browser session. + */ + export type getRelatedWebsiteSetsParameters = { + } + export type getRelatedWebsiteSetsReturnValue = { + sets: RelatedWebsiteSet[]; + } } /** @@ -14582,10 +14646,10 @@ supported. export type SessionID = string; export interface TargetInfo { targetId: TargetID; - type: string; /** * List of types: https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22 */ + type: string; title: string; url: string; /** @@ -16385,7 +16449,7 @@ See also: requestId?: Network.RequestId; /** * Error information -`errorMessage` is null if `errorType` is null. +`errorMessage` is null iff `errorType` is null. */ errorType?: RuleSetErrorType; /** @@ -19515,6 +19579,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeParameters; "CSS.getStyleSheetText": CSS.getStyleSheetTextParameters; "CSS.getLayersForNode": CSS.getLayersForNodeParameters; + "CSS.getLocationForSelector": CSS.getLocationForSelectorParameters; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesParameters; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesParameters; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeParameters; @@ -19885,6 +19950,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsParameters; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeParameters; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingParameters; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsParameters; "SystemInfo.getInfo": SystemInfo.getInfoParameters; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateParameters; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoParameters; @@ -20097,6 +20163,7 @@ Error was thrown. "CSS.getPlatformFontsForNode": CSS.getPlatformFontsForNodeReturnValue; "CSS.getStyleSheetText": CSS.getStyleSheetTextReturnValue; "CSS.getLayersForNode": CSS.getLayersForNodeReturnValue; + "CSS.getLocationForSelector": CSS.getLocationForSelectorReturnValue; "CSS.trackComputedStyleUpdates": CSS.trackComputedStyleUpdatesReturnValue; "CSS.takeComputedStyleUpdates": CSS.takeComputedStyleUpdatesReturnValue; "CSS.setEffectivePropertyValueForNode": CSS.setEffectivePropertyValueForNodeReturnValue; @@ -20467,6 +20534,7 @@ Error was thrown. "Storage.runBounceTrackingMitigations": Storage.runBounceTrackingMitigationsReturnValue; "Storage.setAttributionReportingLocalTestingMode": Storage.setAttributionReportingLocalTestingModeReturnValue; "Storage.setAttributionReportingTracking": Storage.setAttributionReportingTrackingReturnValue; + "Storage.getRelatedWebsiteSets": Storage.getRelatedWebsiteSetsReturnValue; "SystemInfo.getInfo": SystemInfo.getInfoReturnValue; "SystemInfo.getFeatureState": SystemInfo.getFeatureStateReturnValue; "SystemInfo.getProcessInfo": SystemInfo.getProcessInfoReturnValue; From ef57489cf979aef42f662ccf1fd87aeceb6dd3a7 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 Mar 2024 11:27:27 -0700 Subject: [PATCH 138/141] test: iframe is covered by service workers (#30042) References #29267. --- tests/library/capabilities.spec.ts | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 40db91fdfa..2969ac4749 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -302,3 +302,51 @@ it('Intl.ListFormat should work', async ({ page, server, browserName }) => { }); expect(formatted).toBe('first, second, or third'); }); + +it('service worker should cover the iframe', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29267' }); + + server.setRoute('/sw.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/iframe.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(`
from the server
`); + }); + + server.setRoute('/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }).end(` + const kIframeHtml = "
from the service worker
"; + + self.addEventListener('fetch', event => { + if (event.request.url.endsWith('iframe.html')) { + const blob = new Blob([kIframeHtml], { type: 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + return; + } + event.respondWith(fetch(event.request)); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX + '/sw.html'); + await page.evaluate(() => window['activationPromise']); + + await page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.src = '/iframe.html'; + document.body.appendChild(iframe); + }); + + await expect(page.frameLocator('iframe').locator('div')).toHaveText('from the service worker'); +}); From e0588446c040d8062d1329a4a9de02bcf3728471 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 Mar 2024 13:26:02 -0700 Subject: [PATCH 139/141] feat(firefox): roll to 1445 (#30043) --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6f31974b96..beaf0e4901 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -15,13 +15,13 @@ }, { "name": "firefox", - "revision": "1444", + "revision": "1445", "installByDefault": true, "browserVersion": "123.0" }, { "name": "firefox-asan", - "revision": "1444", + "revision": "1445", "installByDefault": false, "browserVersion": "123.0" }, From a9fc4de37e4c4ef3c6a43a38271dc7c5e7ffc2a5 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 21 Mar 2024 14:28:07 -0700 Subject: [PATCH 140/141] chore: queue run and list commands from ui (#30033) --- .../src/isomorphic/testServerConnection.ts | 17 ++++++------ .../src/isomorphic/testServerInterface.ts | 2 +- packages/playwright/src/runner/testServer.ts | 26 +++++++++++-------- packages/trace-viewer/src/ui/uiModeView.tsx | 21 ++++++++++----- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 9c3f84e1ec..3a07fbcb31 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -15,7 +15,7 @@ */ import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface'; -import type { Location, TestError } from 'playwright/types/testReporter'; +import type * as reporterTypes from 'playwright/types/testReporter'; import * as events from './events'; export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents { @@ -110,7 +110,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte } async pingNoReply() { - await this._sendMessageNoReply('ping'); + this._sendMessageNoReply('ping'); } async watch(params: { fileNames: string[]; }): Promise { @@ -121,11 +121,11 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this._sendMessageNoReply('watch', params); } - async open(params: { location: Location; }): Promise { + async open(params: { location: reporterTypes.Location; }): Promise { await this._sendMessage('open', params); } - openNoReply(params: { location: Location; }) { + openNoReply(params: { location: reporterTypes.Location; }) { this._sendMessageNoReply('open', params); } @@ -153,7 +153,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte return await this._sendMessage('runGlobalTeardown'); } - async listFiles(): Promise<{ projects: { name: string; testDir: string; use: { testIdAttribute?: string | undefined; }; files: string[]; }[]; cliEntryPoint?: string | undefined; error?: TestError | undefined; }> { + async listFiles(): Promise<{ projects: { name: string; testDir: string; use: { testIdAttribute?: string | undefined; }; files: string[]; }[]; cliEntryPoint?: string | undefined; error?: reporterTypes.TestError | undefined; }> { return await this._sendMessage('listFiles'); } @@ -161,11 +161,11 @@ export class TestServerConnection implements TestServerInterface, TestServerInte return await this._sendMessage('listTests', params); } - async runTests(params: { reporter?: string | undefined; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise { - await this._sendMessage('runTests', params); + async runTests(params: { reporter?: string | undefined; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise<{ status: reporterTypes.FullResult['status'] }> { + return await this._sendMessage('runTests', params); } - async findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: TestError[] | undefined; }> { + async findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> { return await this._sendMessage('findRelatedTestFiles', params); } @@ -177,7 +177,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this._sendMessageNoReply('stopTests'); } - async closeGracefully(): Promise { await this._sendMessage('closeGracefully'); } diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index c10a273ef6..77fdd6049c 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -66,7 +66,7 @@ export interface TestServerInterface { projects?: string[]; reuseContext?: boolean; connectWsEndpoint?: string; - }): Promise; + }): Promise<{ status: reporterTypes.FullResult['status'] }>; findRelatedTestFiles(params: { files: string[]; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 828c3fbdc0..b915834d24 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -19,7 +19,7 @@ import path from 'path'; import { registry, startTraceViewerServer } from 'playwright-core/lib/server'; import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; import type { Transport, HttpServer } from 'playwright-core/lib/utils'; -import type { FullResult, Location, TestError } from '../../types/testReporter'; +import type * as reporterTypes from '../../types/testReporter'; import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import type { FullConfigInternal } from '../common/config'; import { InternalReporter } from '../reporters/internalReporter'; @@ -93,10 +93,10 @@ class TestServerDispatcher implements TestServerInterface { private _config: FullConfigInternal; private _globalWatcher: Watcher; private _testWatcher: Watcher; - private _testRun: { run: Promise, stop: ManualPromise } | undefined; + private _testRun: { run: Promise, stop: ManualPromise } | undefined; readonly transport: Transport; private _queue = Promise.resolve(); - private _globalCleanup: (() => Promise) | undefined; + private _globalCleanup: (() => Promise) | undefined; readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent']; constructor(config: FullConfigInternal) { @@ -116,7 +116,7 @@ class TestServerDispatcher implements TestServerInterface { async ping() {} - async open(params: { location: Location }) { + async open(params: { location: reporterTypes.Location }) { if (isUnderTest()) return; // eslint-disable-next-line no-console @@ -138,7 +138,7 @@ class TestServerDispatcher implements TestServerInterface { await installBrowsers(); } - async runGlobalSetup(): Promise { + async runGlobalSetup(): Promise { await this.runGlobalTeardown(); const reporter = new InternalReporter(new ListReporter()); @@ -167,7 +167,7 @@ class TestServerDispatcher implements TestServerInterface { const runner = new Runner(this._config); return runner.listTestFiles(); } catch (e) { - const error: TestError = serializeError(e); + const error: reporterTypes.TestError = serializeError(e); error.location = prepareErrorStack(e.stack).location; return { projects: [], error }; } @@ -214,11 +214,15 @@ class TestServerDispatcher implements TestServerInterface { } async runTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) { - this._queue = this._queue.then(() => this._innerRunTests(params)).catch(printInternalError); + let status: reporterTypes.FullResult['status']; + this._queue = this._queue.then(async () => { + status = await this._innerRunTests(params).catch(printInternalError) || 'failed'; + }); await this._queue; + return { status: status! }; } - private async _innerRunTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }) { + private async _innerRunTests(params: { reporter?: string; locations?: string[] | undefined; grep?: string | undefined; testIds?: string[] | undefined; headed?: boolean | undefined; oneWorker?: boolean | undefined; trace?: 'off' | 'on' | undefined; projects?: string[] | undefined; reuseContext?: boolean | undefined; connectWsEndpoint?: string | undefined; }): Promise { await this.stopTests(); const { testIds, projects, locations, grep } = params; @@ -244,7 +248,7 @@ class TestServerDispatcher implements TestServerInterface { return status; }); this._testRun = { run, stop }; - await run; + return await run; } async watch(params: { fileNames: string[]; }) { @@ -256,7 +260,7 @@ class TestServerDispatcher implements TestServerInterface { this._testWatcher.update([...files], [], true); } - findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: TestError[] | undefined; }> { + findRelatedTestFiles(params: { files: string[]; }): Promise<{ testFiles: string[]; errors?: reporterTypes.TestError[] | undefined; }> { const runner = new Runner(this._config); return runner.findRelatedTestFiles('out-of-process', params.files); } @@ -271,7 +275,7 @@ class TestServerDispatcher implements TestServerInterface { } } -export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { +export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise) => Promise): Promise { const testServer = new TestServer(config); const cancelPromise = new ManualPromise(); const sigintWatcher = new SigIntWatcher(); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 58e34a39e6..06bb83bc45 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -66,7 +66,7 @@ export const UIModeView: React.FC<{}> = ({ const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); const [watchAll, setWatchAll] = useSetting('watch-all', false); const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set }>({ value: new Set() }); - const runTestPromiseChain = React.useRef(Promise.resolve()); + const commandQueue = React.useRef(Promise.resolve()); const runTestBacklog = React.useRef>(new Set()); const [collapseAllCount, setCollapseAllCount] = React.useState(0); const [isDisconnected, setIsDisconnected] = React.useState(false); @@ -114,7 +114,6 @@ export const UIModeView: React.FC<{}> = ({ }; }, [testServerConnection]); - // This is the main routine, every time connection updates it starts the // whole workflow. React.useEffect(() => { @@ -141,10 +140,18 @@ export const UIModeView: React.FC<{}> = ({ }); const updateList = async () => { - setIsLoading(true); - const result = await testServerConnection.listTests({}); - teleSuiteUpdater.processListReport(result.report); - setIsLoading(false); + commandQueue.current = commandQueue.current.then(async () => { + setIsLoading(true); + try { + const result = await testServerConnection.listTests({}); + teleSuiteUpdater.processListReport(result.report); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } finally { + setIsLoading(false); + } + }); }; setTestModel(undefined); @@ -221,7 +228,7 @@ export const UIModeView: React.FC<{}> = ({ return; runTestBacklog.current = new Set([...runTestBacklog.current, ...testIds]); - runTestPromiseChain.current = runTestPromiseChain.current.then(async () => { + commandQueue.current = commandQueue.current.then(async () => { const testIds = runTestBacklog.current; runTestBacklog.current = new Set(); if (!testIds.size) From 348d0c2bfad25ba506d1a16b218bc51ef32cf5d1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 21 Mar 2024 15:34:23 -0700 Subject: [PATCH 141/141] test: can register service workers in an iframe (#30045) References #29267. --- tests/library/capabilities.spec.ts | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 2969ac4749..d713260f5e 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -350,3 +350,52 @@ it('service worker should cover the iframe', async ({ page, server }) => { await expect(page.frameLocator('iframe').locator('div')).toHaveText('from the service worker'); }); + +it('service worker should register in an iframe', async ({ page, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29267' }); + + server.setRoute('/main.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/dir/iframe.html', (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }).end(` + + `); + }); + + server.setRoute('/dir/sw.js', (req, res) => { + res.writeHead(200, { 'content-type': 'application/javascript' }).end(` + const kIframeHtml = "
from the service worker
"; + + self.addEventListener('fetch', event => { + if (event.request.url.endsWith('html')) { + event.respondWith(fetch(event.request)); + return; + } + const blob = new Blob(['responseFromServiceWorker'], { type: 'text/plain' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); + + await page.goto(server.PREFIX + '/main.html'); + const iframe = page.frames()[1]; + await iframe.evaluate(() => window['activationPromise']); + + const response = await iframe.evaluate(async () => { + const response = await fetch('foo.txt'); + return response.text(); + }); + expect(response).toBe('responseFromServiceWorker'); +});