From 258881bea10425d98551117dc26fcf083426b9f9 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 13 Dec 2024 17:17:49 +0000 Subject: [PATCH 01/73] test: fix `should not transform external` for newer Node versions (#34006) --- tests/playwright-test/babel.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/playwright-test/babel.spec.ts b/tests/playwright-test/babel.spec.ts index 517d813bc8..340fda91ad 100644 --- a/tests/playwright-test/babel.spec.ts +++ b/tests/playwright-test/babel.spec.ts @@ -143,12 +143,13 @@ test('should not transform external', async ({ runInlineTest }) => { }); `, 'a.spec.ts': ` - import { test, expect } from '@playwright/test'; + const { test, expect, Page } = require('@playwright/test'); + let page: Page; test('succeeds', () => {}); ` }); expect(result.exitCode).toBe(1); - expect(result.output).toMatch(/(Cannot use import statement outside a module|require\(\) of ES Module .* not supported.)/); + expect(result.output).toContain(`SyntaxError: Unexpected token ':'`); }); for (const type of ['module', undefined]) { From e995ecd9b8c8c2578b7af9eee92997ad8a841a0d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Dec 2024 10:38:27 -0800 Subject: [PATCH 02/73] chore: add docs for experimental 'watch mode' (#33988) --- CONTRIBUTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebcabc8a27..440e268f8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,16 @@ npm run watch npx playwright install ``` +**Experimental dev mode with Hot Module Replacement for recorder/trace-viewer/UI Mode** + +``` +PW_HMR=1 npm run watch +PW_HMR=1 npx playwright show-trace +PW_HMR=1 npm run ctest -- --ui +PW_HMR=1 npx playwright codegen +PW_HMR=1 npx playwright show-report +``` + Playwright is a multi-package repository that uses npm workspaces. For browser APIs, look at [`packages/playwright-core`](https://github.com/microsoft/playwright/blob/main/packages/playwright-core). For test runner, see [`packages/playwright`](https://github.com/microsoft/playwright/blob/main/packages/playwright). Note that some files are generated by the build, so the watch process might override your changes if done in the wrong file. For example, TypeScript types for the API are generated from the [`docs/src`](https://github.com/microsoft/playwright/blob/main/docs/src). From 91d4b82dfbbd24701e9b90a2f94e85fd1d91e2fe Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Dec 2024 12:31:38 -0800 Subject: [PATCH 03/73] fix(pwt): custom fixture titles in UI Mode / HTML Reporter (#34009) --- .../playwright/src/worker/fixtureRunner.ts | 4 +-- tests/playwright-test/reporter-html.spec.ts | 32 +++++++++++++++++++ tests/playwright-test/ui-mode-trace.spec.ts | 30 +++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index e67393eb0d..f4fc373610 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -66,7 +66,7 @@ class Fixture { } await testInfo._runAsStage({ - title: `fixture: ${this.registration.name}`, + title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, runnable: { ...runnable, fixture: this._setupDescription }, stepInfo: this._stepInfo, }, async () => { @@ -131,7 +131,7 @@ class Fixture { // time remaining in the time slot. This avoids cascading timeouts. if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) { await testInfo._runAsStage({ - title: `fixture: ${this.registration.name}`, + title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, runnable: fixtureRunnable, stepInfo: this._stepInfo, }, async () => { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 8ec8c6c33c..048976ea23 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1013,6 +1013,38 @@ for (const useIntermediateMergeReport of [true, false] as const) { ]); }); + test('show custom fixture titles', async ({ runInlineTest, showReport, page }) => { + const result = await runInlineTest({ + 'a.spec.js': ` + import { test as base, expect } from '@playwright/test'; + + const test = base.extend({ + fixture1: [async ({}, use) => { + await use(); + }, { title: 'custom fixture name' }], + fixture2: async ({}, use) => { + await use(); + }, + }); + + test('sample', ({ fixture1, fixture2 }) => { + // Empty test using both fixtures + }); + ` + }, { 'reporter': 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + await showReport(); + await page.getByRole('link', { name: 'sample' }).click(); + await page.getByText('Before Hooks').click(); + await expect(page.getByText('fixture: custom fixture name')).toBeVisible(); + await expect(page.locator('.tree-item-title')).toHaveText([ + /Before Hooks/, + /fixture: custom fixture/, + /fixture: fixture2/, + /After Hooks/, + ]); + }); + test('open tests from required file', async ({ runInlineTest, showReport, page }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11742' }); const result = await runInlineTest({ diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 16ebeaf499..ef7c8fcf65 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -363,3 +363,33 @@ test('should filter actions tab on double-click', async ({ runUITest, server }) /page.goto/, ]); }); + +test('should show custom fixture titles in actions tree', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test as base, expect } from '@playwright/test'; + + const test = base.extend({ + fixture1: [async ({}, use) => { + await use(); + }, { title: 'My Custom Fixture' }], + fixture2: async ({}, use) => { + await use(); + }, + }); + + test('fixture test', async ({ fixture1, fixture2 }) => { + // Empty test using both fixtures + }); + `, + }); + + await page.getByText('fixture test').dblclick(); + const listItem = page.getByTestId('actions-tree').getByRole('treeitem'); + await expect(listItem, 'action list').toHaveText([ + /Before Hooks[\d.]+m?s/, + /My Custom Fixture[\d.]+m?s/, + /fixture2[\d.]+m?s/, + /After Hooks[\d.]+m?s/, + ]); +}); From 369f4b76b33d53a8645db93b66f012443f985fea Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Dec 2024 13:38:26 -0800 Subject: [PATCH 04/73] fix: throw an error when object reference chain is to long to serialize (#34008) --- .../src/server/chromium/crExecutionContext.ts | 2 +- .../src/server/webkit/wkExecutionContext.ts | 2 ++ tests/page/page-evaluate.spec.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/chromium/crExecutionContext.ts b/packages/playwright-core/src/server/chromium/crExecutionContext.ts index 1cd58de7af..661d216fb4 100644 --- a/packages/playwright-core/src/server/chromium/crExecutionContext.ts +++ b/packages/playwright-core/src/server/chromium/crExecutionContext.ts @@ -96,7 +96,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { if (error.message.includes('Object reference chain is too long')) - return { result: { type: 'undefined' } }; + throw new Error('Cannot serialize result: object reference chain is too long.'); if (error.message.includes('Object couldn\'t be returned by value')) return { result: { type: 'undefined' } }; diff --git a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts index 52b8ba3677..2b75745cbd 100644 --- a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts @@ -115,6 +115,8 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj } function rewriteError(error: Error): Error { + if (error.message.includes('Object has too long reference chain')) + throw new Error('Cannot serialize result: object reference chain is too long.'); if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error)) return new Error('Execution context was destroyed, most likely because of a navigation.'); return error; diff --git a/tests/page/page-evaluate.spec.ts b/tests/page/page-evaluate.spec.ts index f0a97cf326..8cbe594e27 100644 --- a/tests/page/page-evaluate.spec.ts +++ b/tests/page/page-evaluate.spec.ts @@ -400,6 +400,22 @@ it('should return undefined for non-serializable objects', async ({ page }) => { expect(await page.evaluate(() => function() {})).toBe(undefined); }); +it('should throw for too deep reference chain', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33997' } +}, async ({ page, browserName }) => { + await expect(page.evaluate(depth => { + const obj = {}; + let temp = obj; + for (let i = 0; i < depth; i++) { + temp[i] = {}; + temp = temp[i]; + } + return obj; + }, 1000)).rejects.toThrow(browserName === 'firefox' + ? 'Maximum call stack size exceeded' + : 'Cannot serialize result: object reference chain is too long.'); +}); + it('should alias Window, Document and Node', async ({ page }) => { const object = await page.evaluate('[window, document, document.body]'); expect(object).toEqual(['ref: ', 'ref: ', 'ref: ']); From cbc809edc71bce0eb5b62207ff1dcee59347bb1d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Dec 2024 16:10:45 -0800 Subject: [PATCH 05/73] chore: recorder toolbar polish (#33983) --- .../src/server/injected/highlight.css | 19 +++++++++++++------ .../src/server/injected/recorder/recorder.ts | 16 ++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css index 096f931161..3acfd3fb1c 100644 --- a/packages/playwright-core/src/server/injected/highlight.css +++ b/packages/playwright-core/src/server/injected/highlight.css @@ -149,21 +149,24 @@ x-pw-tools-list { x-pw-tool-item { pointer-events: auto; - cursor: pointer; height: 28px; width: 28px; border-radius: 3px; } +x-pw-tool-item:not(.disabled) { + cursor: pointer; +} + x-pw-tool-item:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } -x-pw-tool-item.active { +x-pw-tool-item.toggled { background-color: rgba(138, 202, 228, 0.5); } -x-pw-tool-item.active:not(.disabled):hover { +x-pw-tool-item.toggled:not(.disabled):hover { background-color: #8acae4c4; } @@ -179,18 +182,22 @@ x-pw-tool-item.disabled > x-div { cursor: default; } -x-pw-tool-item.record.active { +x-pw-tool-item.record.toggled { background-color: transparent; } -x-pw-tool-item.record.active:hover { +x-pw-tool-item.record.toggled:not(.disabled):hover { background-color: hsl(0, 0%, 86%); } -x-pw-tool-item.record.active > x-div { +x-pw-tool-item.record.toggled > x-div { background-color: #a1260d; } +x-pw-tool-item.record.disabled.toggled > x-div { + opacity: 0.8; +} + x-pw-tool-item.accept > x-div { background-color: #388a34; } diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 7374035706..b24feec5d1 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -883,9 +883,13 @@ class Overlay { this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } }; }), addEventListener(this._recordToggle, 'click', () => { + if (this._recordToggle.classList.contains('disabled')) + return; this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby'); }), addEventListener(this._pickLocatorToggle, 'click', () => { + if (this._pickLocatorToggle.classList.contains('disabled')) + return; const newMode: Record = { 'inspecting': 'standby', 'none': 'inspecting', @@ -929,15 +933,15 @@ class Overlay { } setUIState(state: UIState) { - this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting'); - this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); - this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility'); + this._recordToggle.classList.toggle('toggled', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'assertingSnapshot' || state.mode === 'recording-inspecting'); + this._pickLocatorToggle.classList.toggle('toggled', state.mode === 'inspecting' || state.mode === 'recording-inspecting'); + this._assertVisibilityToggle.classList.toggle('toggled', state.mode === 'assertingVisibility'); this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); - this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText'); + this._assertTextToggle.classList.toggle('toggled', state.mode === 'assertingText'); 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('toggled', state.mode === 'assertingValue'); this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); - this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot'); + this._assertSnapshotToggle.classList.toggle('toggled', state.mode === 'assertingSnapshot'); this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting'); if (this._offsetX !== state.overlay.offsetX) { this._offsetX = state.overlay.offsetX; From 3a10c32d8a23eaa0793412a067fbca1b7649bf7a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 13 Dec 2024 16:10:59 -0800 Subject: [PATCH 06/73] chore: report highlight parse error to debug controller (#33984) --- .../src/server/debugController.ts | 15 +++++++++++-- .../src/utils/isomorphic/locatorParser.ts | 21 ++++++++++++------- tests/library/debug-controller.spec.ts | 6 ++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 8878ecb59c..b810e2fa65 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -25,6 +25,9 @@ import { Recorder } from './recorder'; import { EmptyRecorderApp } from './recorder/recorderApp'; import { asLocator, type Language } from '../utils'; import { parseYamlForAriaSnapshot } from './ariaSnapshot'; +import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot'; +import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot'; +import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; const internalMetadata = serverSideCallMetadata(); @@ -144,9 +147,17 @@ export class DebugController extends SdkObject { } async highlight(params: { selector?: string, ariaTemplate?: string }) { + // Assert parameters validity. + if (params.selector) + unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid'); + let parsedYaml: ParsedYaml | undefined; + if (params.ariaTemplate) { + parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate); + parseYamlTemplate(parsedYaml); + } for (const recorder of await this._allRecorders()) { - if (params.ariaTemplate) - recorder.setHighlightedAriaTemplate(parseYamlForAriaSnapshot(params.ariaTemplate)); + if (parsedYaml) + recorder.setHighlightedAriaTemplate(parsedYaml); else if (params.selector) recorder.setHighlightedSelector(this._sdkLanguage, params.selector); } diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index 9bae0a62bd..fff3d078ff 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -216,19 +216,24 @@ function transform(template: string, params: TemplateParams, testIdAttributeName } export function locatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { + try { + return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName); + } catch (e) { + return ''; + } +} + +export function unsafeLocatorOrSelectorAsSelector(language: Language, locator: string, testIdAttributeName: string): string { try { parseSelector(locator); return locator; } catch (e) { } - try { - const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); - const locators = asLocators(language, selector, undefined, undefined, preferredQuote); - const digest = digestForComparison(language, locator); - if (locators.some(candidate => digestForComparison(language, candidate) === digest)) - return selector; - } catch (e) { - } + const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName); + const locators = asLocators(language, selector, undefined, undefined, preferredQuote); + const digest = digestForComparison(language, locator); + if (locators.some(candidate => digestForComparison(language, candidate) === digest)) + return selector; return ''; } diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index c3fb643665..b71cae12a5 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -300,3 +300,9 @@ test('should highlight aria template', async ({ backend, connectedBrowser }, tes const box2 = roundBox(await highlight.boundingBox()); expect(box1).toEqual(box2); }); + +test('should report error in aria template', async ({ backend }) => { + await backend.navigate({ url: `data:text/html,` }); + const error = await backend.highlight({ ariaTemplate: `- button "Submit` }).catch(e => e); + expect(error.message).toContain('Unterminated string:'); +}); From 1e4239f48d323aa332cbf726afc97a2041d71b61 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sat, 14 Dec 2024 10:58:16 -0800 Subject: [PATCH 07/73] chore: allow selecting update source type via test server (#34014) --- packages/playwright/src/common/ipc.ts | 4 ++-- packages/playwright/src/isomorphic/testServerInterface.ts | 3 ++- packages/playwright/src/runner/testServer.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index ad0e91f5c3..909df3dc8f 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -38,8 +38,8 @@ export type ConfigCLIOverrides = { timeout?: number; tsconfig?: string; ignoreSnapshots?: boolean; - updateSnapshots?: 'all'|'changed'|'missing'|'none'; - updateSourceMethod?: 'overwrite'|'patch'|'3way'; + updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; + updateSourceMethod?: 'overwrite' | 'patch' | '3way'; workers?: number | string; projects?: { name: string, use?: any }[], use?: any; diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 257d04c681..694610ecdd 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -94,7 +94,8 @@ export interface TestServerInterface { testIds?: string[]; headed?: boolean; workers?: number | string; - updateSnapshots?: 'all' | 'none' | 'missing'; + updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; + updateSourceMethod?: 'overwrite' | 'patch' | '3way'; reporters?: string[], trace?: 'on' | 'off'; video?: 'on' | 'off'; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 30f724c03e..08fa4b9353 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface { _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, }, ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), + ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), ...(params.workers ? { workers: params.workers } : {}), }; if (params.trace === 'on') From f713d3adaf88bbbab813381e97cf02a6e3bfb837 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Sat, 14 Dec 2024 20:15:58 +0000 Subject: [PATCH 08/73] chore: simplify page initialization logic across browser types (#34002) --- .../src/server/bidi/bidiBrowser.ts | 18 +++----- .../src/server/bidi/bidiPage.ts | 27 +++--------- .../src/server/browserContext.ts | 44 +++++++++---------- .../src/server/chromium/crBrowser.ts | 26 +++++------ .../src/server/chromium/crPage.ts | 42 ++++-------------- .../src/server/firefox/ffBrowser.ts | 18 +++----- .../src/server/firefox/ffPage.ts | 42 +++++------------- packages/playwright-core/src/server/page.ts | 32 +++++++++----- .../src/server/webkit/wkBrowser.ts | 18 +++----- .../src/server/webkit/wkPage.ts | 26 +++-------- 10 files changed, 102 insertions(+), 191 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index acefc08fb2..955f6274a3 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -22,7 +22,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext } from '../browserContext'; import type { SdkObject } from '../instrumentation'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type { BidiSession } from './bidiConnection'; @@ -99,8 +99,8 @@ export class BidiBrowser extends Browser { browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent); await (browser._defaultContext as BidiBrowserContext)._initialize(); // Create default page as we cannot get access to the existing one. - const pageDelegate = await browser._defaultContext.newPageDelegate(); - await pageDelegate.pageOrError(); + const page = await browser._defaultContext.doCreateNewPage(); + await page.waitForInitializedOrError(); } return browser; } @@ -207,21 +207,17 @@ export class BidiBrowserContext extends BrowserContext { return [...this._browser._bidiPages.values()].filter(bidiPage => bidiPage._browserContext === this); } - pages(): Page[] { - return this._bidiPages().map(bidiPage => bidiPage._initializedPage).filter(Boolean) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._bidiPages().map(bidiPage => bidiPage._page); } - pagesOrErrors() { - return this._bidiPages().map(bidiPage => bidiPage.pageOrError()); - } - - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { context } = await this._browser._browserSession.send('browsingContext.create', { type: bidi.BrowsingContext.CreateType.Window, userContext: this._browserContextId, }); - return this._browser._bidiPages.get(context)!; + return this._browser._bidiPages.get(context)!._page; } async doGetCookies(urls: string[]): Promise { diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 56bb43cb1a..9b501c5484 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -43,7 +43,6 @@ export class BidiPage implements PageDelegate { readonly rawKeyboard: RawKeyboardImpl; readonly rawTouchscreen: RawTouchscreenImpl; readonly _page: Page; - private readonly _pagePromise: Promise; readonly _session: BidiSession; readonly _opener: BidiPage | null; private readonly _realmToContext: Map; @@ -51,7 +50,6 @@ export class BidiPage implements PageDelegate { readonly _browserContext: BidiBrowserContext; readonly _networkManager: BidiNetworkManager; private readonly _pdf: BidiPDF; - _initializedPage: Page | null = null; private _initScriptIds: string[] = []; constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { @@ -81,16 +79,10 @@ export class BidiPage implements PageDelegate { ]; // Initialize main frame. - this._pagePromise = this._initialize().finally(async () => { - await this._page.initOpener(this._opener); - }).then(() => { - this._initializedPage = this._page; - this._page.reportAsNew(); - return this._page; - }).catch(e => { - this._page.reportAsNew(e); - return e; - }); + // TODO: Wait for first execution context to be created and maybe about:blank navigated. + this._initialize().then( + () => this._page.reportAsNew(this._opener?._page), + error => this._page.reportAsNew(this._opener?._page, error)); } private async _initialize() { @@ -109,21 +101,12 @@ export class BidiPage implements PageDelegate { return Promise.all(this._page.allInitScripts().map(initScript => this.addInitScript(initScript))); } - potentiallyUninitializedPage(): Page { - return this._page; - } - didClose() { this._session.dispose(); eventsHelper.removeEventListeners(this._sessionListeners); this._page._didClose(); } - async pageOrError(): Promise { - // TODO: Wait for first execution context to be created and maybe about:blank navigated. - return this._pagePromise; - } - private _onFrameAttached(frameId: string, parentFrameId: string | null): frames.Frame { return this._page._frameManager.frameAttached(frameId, parentFrameId); } @@ -372,7 +355,7 @@ export class BidiPage implements PageDelegate { private async _onScriptMessage(event: bidi.Script.MessageParameters) { if (event.channel !== kPlaywrightBindingChannel) return; - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (pageOrError instanceof Error) return; const context = this._realmToContext.get(event.source.realm); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 6d9227c0a7..fc20c52bb5 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -24,7 +24,6 @@ import type * as frames from './frames'; import { helper } from './helper'; import * as network from './network'; import { InitScript } from './page'; -import type { PageDelegate } from './page'; import { Page, PageBinding } from './page'; import type { Progress, ProgressController } from './progress'; import type { Selectors } from './selectors'; @@ -257,10 +256,13 @@ export abstract class BrowserContext extends SdkObject { this.emit(BrowserContext.Events.Close); } + pages(): Page[] { + return this.possiblyUninitializedPages().filter(page => page.initializedOrUndefined()); + } + // BrowserContext methods. - abstract pages(): Page[]; - abstract pagesOrErrors(): Promise[]; - abstract newPageDelegate(): Promise; + abstract possiblyUninitializedPages(): Page[]; + abstract doCreateNewPage(): Promise; abstract addCookies(cookies: channels.SetNetworkCookie[]): Promise; abstract setGeolocation(geolocation?: types.Geolocation): Promise; abstract setExtraHTTPHeaders(headers: types.HeadersArray): Promise; @@ -359,38 +361,34 @@ export abstract class BrowserContext extends SdkObject { this._timeoutSettings.setDefaultTimeout(timeout); } - async _loadDefaultContextAsIs(progress: Progress): Promise { - let pageOrError; - if (!this.pagesOrErrors().length) { + async _loadDefaultContextAsIs(progress: Progress): Promise { + if (!this.possiblyUninitializedPages().length) { const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); progress.cleanupWhenAborted(() => waitForEvent.dispose); // Race against BrowserContext.close - pageOrError = await Promise.race([ - waitForEvent.promise as Promise, - this._closePromise, - ]); - // Consider Page initialization errors - if (pageOrError instanceof Page) - pageOrError = await pageOrError._delegate.pageOrError(); - } else { - pageOrError = await this.pagesOrErrors()[0]; + await Promise.race([waitForEvent.promise, this._closePromise]); } + const page = this.possiblyUninitializedPages()[0]; + if (!page) + return; + const pageOrError = await page.waitForInitializedOrError(); if (pageOrError instanceof Error) throw pageOrError; - await pageOrError.mainFrame()._waitForLoadState(progress, 'load'); - return pageOrError; + await page.mainFrame()._waitForLoadState(progress, 'load'); + return page; } async _loadDefaultContext(progress: Progress) { const defaultPage = await this._loadDefaultContextAsIs(progress); + if (!defaultPage) + return; const browserName = this._browser.options.name; if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) { // Workaround for: // - chromium fails to change isMobile for existing page; // - webkit fails to change locale for existing page. - const oldPage = defaultPage; await this.newPage(progress.metadata); - await oldPage.close(progress.metadata); + await defaultPage.close(progress.metadata); } } @@ -488,10 +486,10 @@ export abstract class BrowserContext extends SdkObject { } async newPage(metadata: CallMetadata): Promise { - const pageDelegate = await this.newPageDelegate(); + const page = await this.doCreateNewPage(); if (metadata.isServerSide) - pageDelegate.potentiallyUninitializedPage().markAsServerSideOnly(); - const pageOrError = await pageDelegate.pageOrError(); + page.markAsServerSideOnly(); + const pageOrError = await page.waitForInitializedOrError(); if (pageOrError instanceof Page) { if (pageOrError.isClosed()) throw new Error('Page has been closed.'); diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 1aba1ed8cb..9f03803dcb 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -21,7 +21,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert, createGuid } from '../../utils'; import * as network from '../network'; -import type { InitScript, PageDelegate, Worker } from '../page'; +import type { InitScript, Worker } from '../page'; import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; @@ -146,7 +146,7 @@ export class CRBrowser extends Browser { } async _waitForAllPagesToBeInitialized() { - await Promise.all([...this._crPages.values()].map(page => page.pageOrError())); + await Promise.all([...this._crPages.values()].map(crPage => crPage._page.waitForInitializedOrError())); } _onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) { @@ -259,10 +259,10 @@ export class CRBrowser extends Browser { } page.willBeginDownload(); - let originPage = page._initializedPage; + let originPage = page._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage && page._opener) - originPage = page._opener._initializedPage; + originPage = page._opener._page.initializedOrUndefined(); if (!originPage) return; this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename); @@ -364,15 +364,11 @@ export class CRBrowserContext extends BrowserContext { return [...this._browser._crPages.values()].filter(crPage => crPage._browserContext === this); } - pages(): Page[] { - return this._crPages().map(crPage => crPage._initializedPage).filter(Boolean) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._crPages().map(crPage => crPage._page); } - pagesOrErrors() { - return this._crPages().map(crPage => crPage.pageOrError()); - } - - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const oldKeys = this._browser.isClank() ? new Set(this._browser._crPages.keys()) : undefined; @@ -395,7 +391,7 @@ export class CRBrowserContext extends BrowserContext { assert(newKeys.size === 1); [targetId] = [...newKeys]; } - return this._browser._crPages.get(targetId)!; + return this._browser._crPages.get(targetId)!._page; } async doGetCookies(urls: string[]): Promise { @@ -548,7 +544,7 @@ export class CRBrowserContext extends BrowserContext { // When persistent context is closed, we do not necessary get Target.detachedFromTarget // for all the background pages. for (const [targetId, backgroundPage] of this._browser._backgroundPages.entries()) { - if (backgroundPage._browserContext === this && backgroundPage._initializedPage) { + if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) { backgroundPage.didClose(); this._browser._backgroundPages.delete(targetId); } @@ -573,8 +569,8 @@ export class CRBrowserContext extends BrowserContext { backgroundPages(): Page[] { const result: Page[] = []; for (const backgroundPage of this._browser._backgroundPages.values()) { - if (backgroundPage._browserContext === this && backgroundPage._initializedPage) - result.push(backgroundPage._initializedPage); + if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) + result.push(backgroundPage._page); } return result; } diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index a7ee34d2c5..0dc4703547 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -65,8 +65,6 @@ export class CRPage implements PageDelegate { private readonly _pdf: CRPDF; private readonly _coverage: CRCoverage; readonly _browserContext: CRBrowserContext; - private readonly _pagePromise: Promise; - _initializedPage: Page | null = null; private _isBackgroundPage: boolean; // Holds window features for the next popup being opened via window.open, @@ -108,30 +106,11 @@ export class CRPage implements PageDelegate { if (viewportSize) this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize }; } - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._pagePromise = this._mainFrameSession._initialize(bits.hasUIWindow).then(async r => { - await this._page.initOpener(this._opener); - return r; - }).catch(async e => { - await this._page.initOpener(this._opener); - throw e; - }).then(() => { - this._initializedPage = this._page; - this._reportAsNew(); - return this._page; - }).catch(e => { - this._reportAsNew(e); - return e; - }); - } - potentiallyUninitializedPage(): Page { - return this._page; - } - - private _reportAsNew(error?: Error) { - this._page.reportAsNew(error, this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page); + const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page; + this._mainFrameSession._initialize(bits.hasUIWindow).then( + () => this._page.reportAsNew(this._opener?._page, undefined, createdEvent), + error => this._page.reportAsNew(this._opener?._page, error, createdEvent)); } private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise) { @@ -168,10 +147,6 @@ export class CRPage implements PageDelegate { this._mainFrameSession._willBeginDownload(); } - async pageOrError(): Promise { - return this._pagePromise; - } - didClose() { for (const session of this._sessions.values()) session.dispose(); @@ -492,7 +467,7 @@ class FrameSession { // Note: it is important to start video recorder before sending Page.startScreencast, // and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger. await this._createVideoRecorder(screencastId, screencastOptions); - this._crPage.pageOrError().then(p => { + this._crPage._page.waitForInitializedOrError().then(p => { if (p instanceof Error) this._stopVideoRecording().catch(() => {}); }); @@ -833,7 +808,7 @@ class FrameSession { } async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { - const pageOrError = await this._crPage.pageOrError(); + const pageOrError = await this._crPage._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(event.executionContextId); if (context) @@ -898,8 +873,7 @@ class FrameSession { } _willBeginDownload() { - const originPage = this._crPage._initializedPage; - if (!originPage) { + if (!this._crPage._page.initializedOrUndefined()) { // Resume the page creation with an error. The page will automatically close right // after the download begins. this._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); @@ -939,7 +913,7 @@ class FrameSession { }); // Wait for the first frame before reporting video to the client. gotFirstFrame.then(() => { - this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError()); + this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage._page.waitForInitializedOrError()); }); } diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index afc0671f70..92998a7946 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -21,7 +21,7 @@ import type { BrowserOptions } from '../browser'; import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import { PageBinding } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; @@ -136,14 +136,14 @@ export class FFBrowser extends Browser { // Abort the navigation that turned into download. ffPage._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); - let originPage = ffPage._initializedPage; + let originPage = ffPage._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage) { // Resume the page creation with an error. The page will automatically close right // after the download begins. ffPage._markAsError(new Error('Starting new page download')); if (ffPage._opener) - originPage = ffPage._opener._initializedPage; + originPage = ffPage._opener._page.initializedOrUndefined(); } if (!originPage) return; @@ -267,15 +267,11 @@ export class FFBrowserContext extends BrowserContext { return Array.from(this._browser._ffPages.values()).filter(ffPage => ffPage._browserContext === this); } - pages(): Page[] { - return this._ffPages().map(ffPage => ffPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._ffPages().map(ffPage => ffPage._page); } - pagesOrErrors() { - return this._ffPages().map(ffPage => ffPage.pageOrError()); - } - - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { targetId } = await this._browser.session.send('Browser.newPage', { browserContextId: this._browserContextId @@ -284,7 +280,7 @@ export class FFBrowserContext extends BrowserContext { throw new Error(`Invalid timezone ID: ${this._options.timezoneId}`); throw e; }); - return this._browser._ffPages.get(targetId)!; + return this._browser._ffPages.get(targetId)!._page; } async doGetCookies(urls: string[]): Promise { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 61790feae4..68559d7c7e 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -34,7 +34,6 @@ import type { Protocol } from './protocol'; import type { Progress } from '../progress'; import { splitErrorMessage } from '../../utils/stackTrace'; import { debugLogger } from '../../utils/debugLogger'; -import { ManualPromise } from '../../utils/manualPromise'; import { BrowserContext } from '../browserContext'; import { TargetClosedError } from '../errors'; @@ -49,9 +48,7 @@ export class FFPage implements PageDelegate { readonly _page: Page; readonly _networkManager: FFNetworkManager; readonly _browserContext: FFBrowserContext; - private _pagePromise = new ManualPromise(); - _initializedPage: Page | null = null; - private _initializationFailed = false; + private _reportedAsNew = false; readonly _opener: FFPage | null; private readonly _contextIdToContext: Map; private _eventListeners: RegisteredListener[]; @@ -102,40 +99,23 @@ export class FFPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.screencastFrame', this._onScreencastFrame.bind(this)), ]; - this._session.once('Page.ready', async () => { - await this._page.initOpener(this._opener); - if (this._initializationFailed) + this._session.once('Page.ready', () => { + if (this._reportedAsNew) return; - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._initializedPage = this._page; - this._page.reportAsNew(); - this._pagePromise.resolve(this._page); + this._reportedAsNew = true; + this._page.reportAsNew(this._opener?._page); }); // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Therefore, we can end up with an initialized page without utility world, although very unlikely. this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); } - potentiallyUninitializedPage(): Page { - return this._page; - } - async _markAsError(error: Error) { - // Same error may be report twice: channer disconnected and session.send fails. - if (this._initializationFailed) + // Same error may be reported twice: channel disconnected and session.send fails. + if (this._reportedAsNew) return; - this._initializationFailed = true; - - if (!this._initializedPage) { - await this._page.initOpener(this._opener); - this._page.reportAsNew(error); - this._pagePromise.resolve(error); - } - } - - async pageOrError(): Promise { - return this._pagePromise; + this._reportedAsNew = true; + this._page.reportAsNew(this._opener?._page, error); } _onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) { @@ -268,7 +248,7 @@ export class FFPage implements PageDelegate { } async _onBindingCalled(event: Protocol.Page.bindingCalledPayload) { - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(event.executionContextId); if (context) @@ -333,7 +313,7 @@ export class FFPage implements PageDelegate { } _onVideoRecordingStarted(event: Protocol.Page.videoRecordingStartedPayload) { - this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this._page.waitForInitializedOrError()); } didClose() { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 48c0827c08..fe483b8347 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -59,8 +59,6 @@ export interface PageDelegate { addInitScript(initScript: InitScript): Promise; removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; - potentiallyUninitializedPage(): Page; - pageOrError(): Promise; navigateFrame(frame: frames.Frame, url: string, referrer: string | undefined): Promise; @@ -139,7 +137,8 @@ export class Page extends SdkObject { private _closedState: 'open' | 'closing' | 'closed' = 'open'; private _closedPromise = new ManualPromise(); - private _initialized = false; + private _initialized: Page | Error | undefined; + private _initializedPromise = new ManualPromise(); private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = []; private _crashed = false; readonly openScope = new LongStandingScope(); @@ -193,15 +192,16 @@ export class Page extends SdkObject { this.coverage = delegate.coverage ? delegate.coverage() : null; } - async initOpener(opener: PageDelegate | null) { - if (!opener) - return; - const openerPage = await opener.pageOrError(); - if (openerPage instanceof Page && !openerPage.isClosed()) - this._opener = openerPage; + async reportAsNew(opener: Page | undefined, error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { + if (opener) { + const openerPageOrError = await opener.waitForInitializedOrError(); + if (openerPageOrError instanceof Page && !openerPageOrError.isClosed()) + this._opener = openerPageOrError; + } + this._markInitialized(error, contextEvent); } - reportAsNew(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { + private _markInitialized(error: Error | undefined = undefined, contextEvent: string = BrowserContext.Events.Page) { if (error) { // Initialization error could have happened because of // context/browser closure. Just ignore the page. @@ -209,7 +209,7 @@ export class Page extends SdkObject { return; this._frameManager.createDummyMainFrameIfNeeded(); } - this._initialized = true; + this._initialized = error || this; this.emitOnContext(contextEvent, this); for (const { event, args } of this._eventsToEmitAfterInitialized) @@ -223,12 +223,20 @@ export class Page extends SdkObject { this.emit(Page.Events.Close); else this.instrumentation.onPageOpen(this); + + // Note: it is important to resolve _initializedPromise at the end, + // so that anyone who awaits waitForInitializedOrError got a ready and reported page. + this._initializedPromise.resolve(this._initialized); } - initializedOrUndefined() { + initializedOrUndefined(): Page | undefined { return this._initialized ? this : undefined; } + waitForInitializedOrError(): Promise { + return this._initializedPromise; + } + emitOnContext(event: string | symbol, ...args: any[]) { if (this._isServerSideOnly) return; diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index 4e5467fd17..86833088d8 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -20,7 +20,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert } from '../../utils'; import * as network from '../network'; -import type { InitScript, Page, PageDelegate } from '../page'; +import type { InitScript, Page } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -121,14 +121,14 @@ export class WKBrowser extends Browser { // abort navigation that is still running. We should be able to fix this by // instrumenting policy decision start/proceed/cancel. page._page._frameManager.frameAbortedNavigation(payload.frameId, 'Download is starting'); - let originPage = page._initializedPage; + let originPage = page._page.initializedOrUndefined(); // If it's a new window download, report it on the opener page. if (!originPage) { // Resume the page creation with an error. The page will automatically close right // after the download begins. page._firstNonInitialNavigationCommittedReject(new Error('Starting new page download')); if (page._opener) - originPage = page._opener._initializedPage; + originPage = page._opener._page.initializedOrUndefined(); } if (!originPage) return; @@ -239,18 +239,14 @@ export class WKBrowserContext extends BrowserContext { return Array.from(this._browser._wkPages.values()).filter(wkPage => wkPage._browserContext === this); } - pages(): Page[] { - return this._wkPages().map(wkPage => wkPage._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[]; + override possiblyUninitializedPages(): Page[] { + return this._wkPages().map(wkPage => wkPage._page); } - pagesOrErrors() { - return this._wkPages().map(wkPage => wkPage.pageOrError()); - } - - async newPageDelegate(): Promise { + override async doCreateNewPage(): Promise { assertBrowserContextIsNotOwned(this); const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId }); - return this._browser._wkPages.get(pageProxyId)!; + return this._browser._wkPages.get(pageProxyId)!._page; } async doGetCookies(urls: string[]): Promise { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index a1e69e1267..15005f589b 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -43,7 +43,6 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; import { debugLogger } from '../../utils/debugLogger'; -import { ManualPromise } from '../../utils/manualPromise'; import { BrowserContext } from '../browserContext'; import { TargetClosedError } from '../errors'; @@ -56,7 +55,6 @@ export class WKPage implements PageDelegate { _session: WKSession; private _provisionalPage: WKProvisionalPage | null = null; readonly _page: Page; - private readonly _pagePromise = new ManualPromise(); private readonly _pageProxySession: WKSession; readonly _opener: WKPage | null; private readonly _requestIdToRequest = new Map(); @@ -66,7 +64,6 @@ export class WKPage implements PageDelegate { private _sessionListeners: RegisteredListener[] = []; private _eventListeners: RegisteredListener[]; readonly _browserContext: WKBrowserContext; - _initializedPage: Page | null = null; private _firstNonInitialNavigationCommittedPromise: Promise; private _firstNonInitialNavigationCommittedFulfill = () => {}; _firstNonInitialNavigationCommittedReject = (e: Error) => {}; @@ -111,10 +108,6 @@ export class WKPage implements PageDelegate { } } - potentiallyUninitializedPage(): Page { - return this._page; - } - private async _initializePageProxySession() { if (this._page._browserContext.isSettingStorageState()) return; @@ -283,7 +276,7 @@ export class WKPage implements PageDelegate { } handleProvisionalLoadFailed(event: Protocol.Playwright.provisionalLoadFailedPayload) { - if (!this._initializedPage) { + if (!this._page.initializedOrUndefined()) { this._firstNonInitialNavigationCommittedReject(new Error('Initial load failed')); return; } @@ -300,10 +293,6 @@ export class WKPage implements PageDelegate { this._nextWindowOpenPopupFeatures = event.windowFeatures; } - async pageOrError(): Promise { - return this._pagePromise; - } - private async _onTargetCreated(event: Protocol.Target.targetCreatedPayload) { const { targetInfo } = event; const session = new WKSession(this._pageProxySession.connection, targetInfo.targetId, (message: any) => { @@ -316,7 +305,7 @@ export class WKPage implements PageDelegate { assert(targetInfo.type === 'page', 'Only page targets are expected in WebKit, received: ' + targetInfo.type); if (!targetInfo.isProvisional) { - assert(!this._initializedPage); + assert(!this._page.initializedOrUndefined()); let pageOrError: Page | Error; try { this._setSession(session); @@ -343,12 +332,7 @@ export class WKPage implements PageDelegate { // Avoid rejection on disconnect. this._firstNonInitialNavigationCommittedPromise.catch(() => {}); } - await this._page.initOpener(this._opener); - // Note: it is important to call |reportAsNew| before resolving pageOrError promise, - // so that anyone who awaits pageOrError got a ready and reported page. - this._initializedPage = pageOrError instanceof Page ? pageOrError : null; - this._page.reportAsNew(pageOrError instanceof Page ? undefined : pageOrError); - this._pagePromise.resolve(pageOrError); + this._page.reportAsNew(this._opener?._page, pageOrError instanceof Page ? undefined : pageOrError); } else { assert(targetInfo.isProvisional); assert(!this._provisionalPage); @@ -515,7 +499,7 @@ export class WKPage implements PageDelegate { } private async _onBindingCalled(contextId: Protocol.Runtime.ExecutionContextId, argument: string) { - const pageOrError = await this.pageOrError(); + const pageOrError = await this._page.waitForInitializedOrError(); if (!(pageOrError instanceof Error)) { const context = this._contextIdToContext.get(contextId); if (context) @@ -821,7 +805,7 @@ export class WKPage implements PageDelegate { toolbarHeight: this._toolbarHeight() }); this._recordingVideoFile = options.outputFile; - this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this._page.waitForInitializedOrError()); } async _stopVideo(): Promise { From aa1fe61cc951322ba22c9b28b20732a8c8f0a39a Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Dec 2024 10:15:52 +0000 Subject: [PATCH 09/73] fix(list reporter): do not print step location instead of test location (#34022) --- packages/playwright/src/reporters/base.ts | 2 +- tests/playwright-test/reporter-line.spec.ts | 2 +- tests/playwright-test/reporter-list.spec.ts | 32 ++++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 9317e4e1bc..7a081e9f85 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -381,7 +381,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS if (omitLocation) location = `${relativeTestPath(config, test)}`; else - location = `${relativeTestPath(config, test)}:${step?.location?.line ?? test.location.line}:${step?.location?.column ?? test.location.column}`; + location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`; const projectTitle = projectName ? `[${projectName}] › ` : ''; const testTitle = `${projectTitle}${location} › ${titles.join(' › ')}`; const extraTags = test.tags.filter(t => !testTitle.includes(t)); diff --git a/tests/playwright-test/reporter-line.spec.ts b/tests/playwright-test/reporter-line.spec.ts index 14959877b5..22441d567a 100644 --- a/tests/playwright-test/reporter-line.spec.ts +++ b/tests/playwright-test/reporter-line.spec.ts @@ -127,7 +127,7 @@ for (const useIntermediateMergeReport of [false, true] as const) { `, }, { reporter: 'line' }); const text = result.output; - expect(text).toContain('[1/1] a.test.ts:6:26 › passes › outer › inner'); + expect(text).toContain('[1/1] a.test.ts:3:15 › passes › outer › inner'); expect(result.exitCode).toBe(0); }); diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts index c57190a5d7..752d29c649 100644 --- a/tests/playwright-test/reporter-list.spec.ts +++ b/tests/playwright-test/reporter-list.spec.ts @@ -111,17 +111,17 @@ for (const useIntermediateMergeReport of [false, true] as const) { lines.pop(); // Remove last item that contains [v] and time in ms. expect(lines).toEqual([ '#0 : 1 a.test.ts:3:11 › passes', - '#0 : 1 a.test.ts:4:20 › passes › outer 1.0', - '#0 : 1 a.test.ts:5:22 › passes › outer 1.0 › inner 1.1', - '#0 : 1 a.test.ts:4:20 › passes › outer 1.0', - '#0 : 1 a.test.ts:6:22 › passes › outer 1.0 › inner 1.2', - '#0 : 1 a.test.ts:4:20 › passes › outer 1.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 1.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 1.0 › inner 1.1', + '#0 : 1 a.test.ts:3:11 › passes › outer 1.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 1.0 › inner 1.2', + '#0 : 1 a.test.ts:3:11 › passes › outer 1.0', '#0 : 1 a.test.ts:3:11 › passes', - '#0 : 1 a.test.ts:8:20 › passes › outer 2.0', - '#0 : 1 a.test.ts:9:22 › passes › outer 2.0 › inner 2.1', - '#0 : 1 a.test.ts:8:20 › passes › outer 2.0', - '#0 : 1 a.test.ts:10:22 › passes › outer 2.0 › inner 2.2', - '#0 : 1 a.test.ts:8:20 › passes › outer 2.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 2.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 2.0 › inner 2.1', + '#0 : 1 a.test.ts:3:11 › passes › outer 2.0', + '#0 : 1 a.test.ts:3:11 › passes › outer 2.0 › inner 2.2', + '#0 : 1 a.test.ts:3:11 › passes › outer 2.0', '#0 : 1 a.test.ts:3:11 › passes', ]); }); @@ -145,12 +145,12 @@ for (const useIntermediateMergeReport of [false, true] as const) { const text = result.output; const lines = text.split('\n').filter(l => l.match(/^#.* :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms')); expect(lines).toEqual([ - '#0 : 1.1 a.test.ts:5:26 › passes › outer 1.0 › inner 1.1 (Xms)', - '#1 : 1.2 a.test.ts:6:26 › passes › outer 1.0 › inner 1.2 (Xms)', - '#2 : 1.3 a.test.ts:4:24 › passes › outer 1.0 (Xms)', - '#3 : 1.4 a.test.ts:9:26 › passes › outer 2.0 › inner 2.1 (Xms)', - '#4 : 1.5 a.test.ts:10:26 › passes › outer 2.0 › inner 2.2 (Xms)', - '#5 : 1.6 a.test.ts:8:24 › passes › outer 2.0 (Xms)', + '#0 : 1.1 a.test.ts:3:15 › passes › outer 1.0 › inner 1.1 (Xms)', + '#1 : 1.2 a.test.ts:3:15 › passes › outer 1.0 › inner 1.2 (Xms)', + '#2 : 1.3 a.test.ts:3:15 › passes › outer 1.0 (Xms)', + '#3 : 1.4 a.test.ts:3:15 › passes › outer 2.0 › inner 2.1 (Xms)', + '#4 : 1.5 a.test.ts:3:15 › passes › outer 2.0 › inner 2.2 (Xms)', + '#5 : 1.6 a.test.ts:3:15 › passes › outer 2.0 (Xms)', `#6 : ${POSITIVE_STATUS_MARK} 1 a.test.ts:3:15 › passes (Xms)`, ]); }); From 6270918f6774ca0296d5336829ce0eec2c70e025 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 16 Dec 2024 05:18:54 -0800 Subject: [PATCH 10/73] docs: Moved Trace Viewer running instructions to the top of the page (#33956) --- docs/src/test-ui-mode-js.md | 2 +- docs/src/trace-viewer-intro-js.md | 2 +- docs/src/trace-viewer.md | 230 +++++++++++++++--------------- 3 files changed, 118 insertions(+), 116 deletions(-) diff --git a/docs/src/test-ui-mode-js.md b/docs/src/test-ui-mode-js.md index 4fb021e6f6..41a00264d9 100644 --- a/docs/src/test-ui-mode-js.md +++ b/docs/src/test-ui-mode-js.md @@ -7,7 +7,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube'; ## Introduction -UI Mode lets you explore, run and debug tests with a time travel experience complete with watch mode. All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test. Filter tests by **text** or **@tag** or by **passed**, **failed** and **skipped** tests as well as by [**projects**](./test-projects) as set in your `playwright.config` file. See a full trace of your tests and hover back and forward over each action to see what was happening during each step and pop out the DOM snapshot to a separate window for a better debugging experience. +UI Mode lets you explore, run, and debug tests with a time travel experience complete with a watch mode. All test files are displayed in the testing sidebar, allowing you to expand each file and describe block to individually run, view, watch, and debug each test. Filter tests by **name**, [**projects**](./test-projects) (set in your `playwright.config` file), **@tag**, or by the execution status of **passed**, **failed**, and **skipped**. See a full trace of your tests and hover back and forward over each action to see what was happening during each step. You can also pop out the DOM snapshot of a given moment into a separate window for a better debugging experience. -## Trace Viewer features -### Actions +## Opening Trace Viewer -In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. +You can open a saved trace using either the Playwright CLI or in the browser at [trace.playwright.dev](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. -![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) +```bash js +npx playwright show-trace path/to/trace.zip +``` -**Selecting each action reveals:** -- action snapshots -- action log -- source code location +```bash java +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" +``` -### Screenshots +```bash python +playwright show-trace trace.zip +``` -When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. +```bash csharp +pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip +``` -Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. +### Using [trace.playwright.dev](https://trace.playwright.dev) -![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8) +[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop or via the `Select file(s)` button. +Trace Viewer loads the trace entirely in your browser and does not transmit any data externally. -### Snapshots +Drop Playwright Trace to load -When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture: +### Viewing remote traces -| Type | Description | -|------|-------------| -|Before|A snapshot at the time action is called.| -|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.| -|After|A snapshot after the action.| +You can open remote traces directly using its URL. This makes it easy to view the remote trace without having to manually download the file from CI runs, for example. -Here is what the typical Action snapshot looks like: +```bash js +npx playwright show-trace https://example.com/trace.zip +``` -![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9) +```bash java +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" +``` -Notice how it highlights both, the DOM Node as well as the exact click position. +```bash python +playwright show-trace https://example.com/trace.zip +``` -### Source +```bash csharp +pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip +``` -When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel. +When using [trace.playwright.dev](https://trace.playwright.dev), you can also pass the URL of your uploaded trace at some accessible storage (e.g. inside your CI) as a query parameter. CORS (Cross-Origin Resource Sharing) rules might apply. -![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5) +```txt +https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip +``` -### Call - -The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used. - -![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2) - -### Log - -See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc. - -![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf) - -### Errors - -If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is. - -![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34) - -### Console - -See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file. - -![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a) - -Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again. - -Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected. - - -### Network - -The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body. - -![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236) - -Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again. - -Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected. - -### Metadata - -Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more. - -![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091) - -### Attachments +## Recording a trace * langs: js -The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots. - -![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04) - - -## Recording a trace locally +### Tracing locally * langs: js -To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience. +To record a trace during development mode set the `--trace` flag to `on` when running your tests. You can also use [UI Mode](./test-ui-mode.md) for a better developer experience, as it traces each test automatically. ```bash npx playwright test --trace on @@ -126,7 +87,7 @@ You can then open the HTML report and click on the trace icon to open the trace. ```bash npx playwright show-report ``` -## Recording a trace on CI +### Tracing on CI * langs: js Traces should be run on continuous integration on the first retry of a failed test @@ -592,57 +553,98 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute -## Opening the trace +## Trace Viewer features +### Actions -You can open the saved trace using the Playwright CLI or in your browser on [`trace.playwright.dev`](https://trace.playwright.dev). Make sure to add the full path to where your `trace.zip` file is located. +In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. -```bash js -npx playwright show-trace path/to/trace.zip -``` +![actions tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/948b65cd-f0fd-4c7f-8e53-2c632b5a07f1) -```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace trace.zip" -``` +**Selecting each action reveals:** +- Action snapshots +- Action log +- Source code location -```bash python -playwright show-trace trace.zip -``` +### Screenshots -```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace trace.zip -``` +When tracing with the [`option: Tracing.start.screenshots`] option turned on (default), each trace records a screencast and renders it as a film strip. You can hover over the film strip to see a magnified image of for each action and state which helps you easily find the action you want to inspect. -## Using [trace.playwright.dev](https://trace.playwright.dev) +Double click on an action to see the time range for that action. You can use the slider in the timeline to increase the actions selected and these will be shown in the Actions tab and all console logs and network logs will be filtered to only show the logs for the actions selected. -[trace.playwright.dev](https://trace.playwright.dev) is a statically hosted variant of the Trace Viewer. You can upload trace files using drag and drop. +![timeline view in trace viewer](https://github.com/microsoft/playwright/assets/13063165/b04a7d75-54bb-4ab2-9e30-e76f6f74a2c8) -Drop Playwright Trace to load +### Snapshots -## Viewing remote traces +When tracing with the [`option: Tracing.start.snapshots`] option turned on (default), Playwright captures a set of complete DOM snapshots for each action. Depending on the type of the action, it will capture: -You can open remote traces using its URL. They could be generated on a CI run which makes it easy to view the remote trace without having to manually download the file. +| Type | Description | +|------|-------------| +|Before|A snapshot at the time action is called.| +|Action|A snapshot at the moment of the performed input. This type of snapshot is especially useful when exploring where exactly Playwright clicked.| +|After|A snapshot after the action.| -```bash js -npx playwright show-trace https://example.com/trace.zip -``` +Here is what the typical Action snapshot looks like: -```bash java -mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="show-trace https://example.com/trace.zip" -``` +![action tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/7168d549-eb0a-4964-9c93-483f03711fa9) -```bash python -playwright show-trace https://example.com/trace.zip -``` +Notice how it highlights both, the DOM Node as well as the exact click position. -```bash csharp -pwsh bin/Debug/netX/playwright.ps1 show-trace https://example.com/trace.zip -``` +### Source + +When you click on an action in the sidebar, the line of code for that action is highlighted in the source panel. + +![showing source code tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/daa8845d-c250-4923-aa7a-5d040da9adc5) + +### Call + +The call tab shows you information about the action such as the time it took, what locator was used, if in strict mode and what key was used. + +![showing call tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/95498580-f9dd-4932-a123-c37fe7cfc3c2) + +### Log + +See a full log of your test to better understand what Playwright is doing behind the scenes such as scrolling into view, waiting for element to be visible, enabled and stable and performing actions such as click, fill, press etc. + +![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/de621461-3bab-4140-b39d-9f02d6672dbf) + +### Errors + +If your test fails you will see the error messages for each test in the Errors tab. The timeline will also show a red line highlighting where the error occurred. You can also click on the source tab to see on which line of the source code the error is. + +![showing errors in trace viewer](https://github.com/microsoft/playwright/assets/13063165/e9ef77b3-05d1-4df2-852c-981023723d34) + +### Console + +See console logs from the browser as well as from your test. Different icons are displayed to show you if the console log came from the browser or from the test file. + +![showing log of tests in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4107c08d-1eaf-421c-bdd4-9dd2aa641d4a) + +Double click on an action from your test in the actions sidebar. This will filter the console to only show the logs that were made during that action. Click the *Show all* button to see all console logs again. + +Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The console tab will also be filtered to only show the logs that were made during the actions selected. -You can also pass the URL of your uploaded trace (e.g. inside your CI) from some accessible storage as a parameter. CORS (Cross-Origin Resource Sharing) rules might apply. +### Network -```txt -https://trace.playwright.dev/?trace=https://demo.playwright.dev/reports/todomvc/data/cb0fa77ebd9487a5c899f3ae65a7ffdbac681182.zip -``` +The Network tab shows you all the network requests that were made during your test. You can sort by different types of requests, status code, method, request, content type, duration and size. Click on a request to see more information about it such as the request headers, response headers, request body and response body. + +![network requests tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/0a3d1671-8ccd-4f7a-a844-35f5eb37f236) + +Double click on an action from your test in the actions sidebar. This will filter the network requests to only show the requests that were made during that action. Click the *Show all* button to see all network requests again. + +Use the timeline to filter actions, by clicking a start point and dragging to an ending point. The network tab will also be filtered to only show the network requests that were made during the actions selected. + +### Metadata + +Next to the Actions tab you will find the Metadata tab which will show you more information on your test such as the Browser, viewport size, test duration and more. + +![meta data in trace viewer](https://github.com/microsoft/playwright/assets/13063165/82ab3d33-1ec9-4b8a-9cf2-30a6e2d59091) + +### Attachments +* langs: js + +The "Attachments" tab allows you to explore attachments. If you're doing [visual regression testing](./test-snapshots.md), you'll be able to compare screenshots by examining the image diff, the actual image and the expected image. When you click on the expected image you can use the slider to slide one image over the other so you can easily see the differences in your screenshots. + +![attachments tab in trace viewer](https://github.com/microsoft/playwright/assets/13063165/4386178a-5808-4fa8-9436-315350a23b04) From 512cb36c9bb1b454ac861876a673cdaa17a0b8ca Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 16 Dec 2024 15:25:32 +0100 Subject: [PATCH 11/73] feat(html): link from attachment step to attachment (#33267) --- packages/html-reporter/src/chip.tsx | 4 +- packages/html-reporter/src/links.tsx | 48 +++++++++----- packages/html-reporter/src/testCaseView.tsx | 6 +- packages/html-reporter/src/testFileView.tsx | 18 +++--- packages/html-reporter/src/testResultView.tsx | 64 +++++++++++-------- packages/html-reporter/src/treeItem.css | 5 ++ packages/html-reporter/src/treeItem.tsx | 4 +- tests/playwright-test/reporter-html.spec.ts | 24 ++++++- 8 files changed, 116 insertions(+), 57 deletions(-) diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index f94dcbc6d6..cdd07777a6 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -20,7 +20,7 @@ import './colors.css'; import './common.css'; import * as icons from './icons'; import { clsx } from '@web/uiUtils'; -import { useAnchor } from './links'; +import { type AnchorID, useAnchor } from './links'; export const Chip: React.FC<{ header: JSX.Element | string, @@ -53,7 +53,7 @@ export const AutoChip: React.FC<{ noInsets?: boolean, children?: any, dataTestId?: string, - revealOnAnchorId?: string, + revealOnAnchorId?: AnchorID, }> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => { const [expanded, setExpanded] = React.useState(initialExpanded ?? true); const onReveal = React.useCallback(() => setExpanded(true), []); diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 1e5cad48c1..a6ea1e6695 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { TestAttachment } from './types'; +import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types'; import * as React from 'react'; import * as icons from './icons'; import { TreeItem } from './treeItem'; @@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, href, linkName, openInNewTab }) => { + const isAnchored = useIsAnchored('attachment-' + attachment.name); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} @@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{ )} } loadChildren={attachment.body ? () => { return [
{linkifyText(attachment.body!)}
]; - } : undefined} depth={0} style={{ lineHeight: '32px' }}>
; + } : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}>; }; export const SearchParamsContext = React.createContext(new URLSearchParams(window.location.hash.slice(1))); @@ -114,23 +115,29 @@ export function generateTraceUrl(traces: TestAttachment[]) { const kMissingContentType = 'x-playwright/missing'; -type AnchorID = string | ((id: string | null) => boolean) | undefined; +export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export function useAnchor(id: AnchorID, onReveal: () => void) { + const searchParams = React.useContext(SearchParamsContext); + const isAnchored = useIsAnchored(id); React.useEffect(() => { - if (typeof id === 'undefined') - return; + if (isAnchored) + onReveal(); + }, [isAnchored, onReveal, searchParams]); +} - const listener = () => { - const params = new URLSearchParams(window.location.hash.slice(1)); - const anchor = params.get('anchor'); - const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id; - if (isRevealed) - onReveal(); - }; - window.addEventListener('popstate', listener); - return () => window.removeEventListener('popstate', listener); - }, [id, onReveal]); +export function useIsAnchored(id: AnchorID) { + const searchParams = React.useContext(SearchParamsContext); + const anchor = searchParams.get('anchor'); + if (anchor === null) + return false; + if (typeof id === 'undefined') + return false; + if (typeof id === 'string') + return id === anchor; + if (Array.isArray(id)) + return id.includes(anchor); + return id(anchor); } export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) { @@ -142,3 +149,14 @@ export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID return
{children}
; } + +export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) { + const params = new URLSearchParams(); + if (test) + params.set('testId', test.testId); + if (test && result) + params.set('run', '' + test.results.indexOf(result as any)); + if (anchor) + params.set('anchor', anchor); + return `#?` + params; +} diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 4e9785ad8a..e4ffa9c15b 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { TabbedPane } from './tabbedPane'; import { AutoChip } from './chip'; import './common.css'; -import { Link, ProjectLink, SearchParamsContext } from './links'; +import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; @@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{ {test &&
{test.path.join(' › ')}
-
« previous
+
« previous
-
next »
+
next »
} {test &&
{test?.title}
} {test &&
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 6b31d2ebe2..f8fad1d646 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { hashStringToInt, msToString } from './utils'; import { Chip } from './chip'; import { filterWithToken } from './filter'; -import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links'; +import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; @@ -48,7 +48,7 @@ export const TestFileView: React.FC - + {[...test.path, test.title].join(' › ')} {projectNames.length > 1 && !!test.projectName && @@ -59,7 +59,7 @@ export const TestFileView: React.FC{msToString(test.duration)}
- + {test.location.file}:{test.location.line} {imageDiffBadge(test)} @@ -72,15 +72,17 @@ export const TestFileView: React.FC result.attachments.some(attachment => { - return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/); - })); - return resultWithImageDiff ? {image()} : undefined; + for (const result of test.results) { + for (const attachment of result.attachments) { + if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) + return {image()}; + } + } } function videoBadge(test: TestCaseSummary): JSX.Element | undefined { const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video')); - return resultWithVideo ? {video()} : undefined; + return resultWithVideo ? {video()} : undefined; } function traceBadge(test: TestCaseSummary): JSX.Element | undefined { diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 9170f2023d..410677cb02 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -20,15 +20,20 @@ import { TreeItem } from './treeItem'; import { msToString } from './utils'; import { AutoChip } from './chip'; import { traceImage } from './images'; -import { Anchor, AttachmentLink, generateTraceUrl } from './links'; +import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; +import * as icons from './icons'; import './testResultView.css'; -function groupImageDiffs(screenshots: Set): ImageDiff[] { - const snapshotNameToImageDiff = new Map(); +interface ImageDiffWithAnchors extends ImageDiff { + anchors: string[]; +} + +function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors[] { + const snapshotNameToImageDiff = new Map(); for (const attachment of screenshots) { const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); if (!match) @@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { const snapshotName = name + extension; let imageDiff = snapshotNameToImageDiff.get(snapshotName); if (!imageDiff) { - imageDiff = { name: snapshotName }; + imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; snapshotNameToImageDiff.set(snapshotName, imageDiff); } + imageDiff.anchors.push(`attachment-${attachment.name}`); if (category === 'actual') imageDiff.actual = { attachment }; if (category === 'expected') @@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { export const TestResultView: React.FC<{ test: TestCase, result: TestResult, -}> = ({ result }) => { - const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => { +}> = ({ test, result }) => { + const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { const attachments = result?.attachments || []; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); + const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`); const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); - const htmls = attachments.filter(a => a.contentType.startsWith('text/html')); const otherAttachments = new Set(attachments); - [...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a)); + [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); + const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); const diffs = groupImageDiffs(screenshots); const errors = classifyErrors(result.errors, diffs); - return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls }; + return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; }, [result]); return
@@ -87,29 +94,29 @@ export const TestResultView: React.FC<{ })} } {!!result.steps.length && - {result.steps.map((step, i) => )} + {result.steps.map((step, i) => )} } {diffs.map((diff, index) => - - + + )} - {!!screenshots.length && + {!!screenshots.length && {screenshots.map((a, i) => { - return
+ return -
; +
; })} } - {!!traces.length && + {!!traces.length && {} } - {!!videos.length && + {!!videos.length && {videos.map((a, i) =>
)}
} - {!!(otherAttachments.size + htmls.length) && - {[...htmls].map((a, i) => ( - ) + {!!otherAttachments.size && + {[...otherAttachments].map((a, i) => + + + )} - {[...otherAttachments].map((a, i) => )} }
; }; @@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { } const StepTreeItem: React.FC<{ + test: TestCase; + result: TestResult; step: TestStep; depth: number, -}> = ({ step, depth }) => { - return +}> = ({ test, step, result, depth }) => { + const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1]; + return {msToString(step.duration)} + {attachmentName && { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {step.title} {step.count > 1 && <> ✕ {step.count}} {step.location && — {step.location.file}:{step.location.line}} } loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => { - const children = step.steps.map((s, i) => ); + const children = step.steps.map((s, i) => ); if (step.snippet) - children.unshift(); + children.unshift(); return children; - } : undefined} depth={depth}>; + } : undefined} depth={depth}/>; }; diff --git a/packages/html-reporter/src/treeItem.css b/packages/html-reporter/src/treeItem.css index a8cedc4f6a..f37a759c2d 100644 --- a/packages/html-reporter/src/treeItem.css +++ b/packages/html-reporter/src/treeItem.css @@ -25,6 +25,11 @@ cursor: pointer; } +.tree-item-title.selected { + text-decoration: underline var(--color-underlinenav-icon); + text-decoration-thickness: 1.5px; +} + .tree-item-body { min-height: 18px; } diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 507a9c0e71..926a398a05 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import './treeItem.css'; import * as icons from './icons'; +import { clsx } from '@web/uiUtils'; export const TreeItem: React.FunctionComponent<{ title: JSX.Element, @@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{ style?: React.CSSProperties, }> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); - const className = selected ? 'tree-item-title selected' : 'tree-item-title'; return
- { onClick?.(); setExpanded(!expanded); }} > + { onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} {!loadChildren && {icons.rightArrow()}} diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 048976ea23..5568455d63 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -847,7 +847,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'a.test.js': ` import { test, expect } from '@playwright/test'; test('passing', async ({ page }, testInfo) => { - testInfo.attach('axe-report.html', { + await testInfo.attach('axe-report.html', { contentType: 'text/html', body: '

Axe Report

', }); @@ -916,6 +916,28 @@ for (const useIntermediateMergeReport of [true, false] as const) { ])); }); + test('should link from attach step to attachment view', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passing', async ({ page }, testInfo) => { + for (let i = 0; i < 100; i++) + await testInfo.attach('foo-1', { body: 'bar' }); + await testInfo.attach('foo-2', { body: 'bar' }); + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + + await showReport(); + await page.getByRole('link', { name: 'passing' }).click(); + + const attachment = page.getByText('foo-2', { exact: true }); + await expect(attachment).not.toBeInViewport(); + await page.getByLabel('attach "foo-2"').getByTitle('link to attachment').click(); + await expect(attachment).toBeInViewport(); + }); + test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'helper.ts': ` From d4b2c966cf6668ad808b7305943fa85ebbaca23c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Dec 2024 17:37:53 +0000 Subject: [PATCH 12/73] fix(codegen): fallback to `iframe[name/src]` when failed to generate selector (#34030) --- packages/playwright-core/src/server/recorder/contextRecorder.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index d7a3c908e8..2e4f445087 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi }, frameElement); return selector; } catch (e) { - return e.toString(); } }, monotonicTime() + 2000); if (!result.timedOut && result.result) From 76bb01d77c0fd72e96da641854b06c8ffd6e39e2 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 16 Dec 2024 13:35:19 -0800 Subject: [PATCH 13/73] chore: rephrase suggestion for slow test files (#34012) --- packages/playwright/src/reporters/base.ts | 2 +- tests/playwright-test/reporter-base.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 7a081e9f85..38b69d9d65 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 { console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`)); }); if (slowTests.length) - console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution')); + console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.')); } private _printSummary(summary: string) { diff --git a/tests/playwright-test/reporter-base.spec.ts b/tests/playwright-test/reporter-base.spec.ts index 3e398f9160..afd5aa0543 100644 --- a/tests/playwright-test/reporter-base.spec.ts +++ b/tests/playwright-test/reporter-base.spec.ts @@ -222,7 +222,7 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(result.output).toContain(`Slow test file: [bar] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [baz] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [qux] › dir${path.sep}a.test.js (`); - expect(result.output).toContain(`Consider splitting slow test files to speed up parallel execution`); + expect(result.output).toContain(`Consider running tests from slow files in parallel`); expect(result.output).not.toContain(`Slow test file: [foo] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [bar] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [baz] › dir${path.sep}b.test.js (`); From b58a4762f46a146fcedd513a444248bbc55c6f42 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 16 Dec 2024 13:52:17 -0800 Subject: [PATCH 14/73] docs: improve note on `browser.close()` behavior (#34039) --- docs/src/api/class-browser.md | 2 +- packages/playwright-core/types/types.d.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 4dabfc52e4..7867ce5c8b 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t browser server. :::note -This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. +This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. ::: The [Browser] object itself is considered to be disposed and cannot be used anymore. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cb83bb4ccd..ba83204e7e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9589,10 +9589,11 @@ export interface Browser { * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from * the browser server. * - * **NOTE** This is similar to force quitting the browser. Therefore, you should call + * **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close + * events, call * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier + * using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). * * The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot From 94d0fc780db9f8ef1de44e957c955b16e855afa4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 16 Dec 2024 14:14:51 -0800 Subject: [PATCH 15/73] chore: make visible=false work (#34040) --- .../src/server/injected/injectedScript.ts | 3 ++- tests/page/selectors-misc.spec.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74ff799ff1..a1ffdf893c 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -457,7 +457,8 @@ export class InjectedScript { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; + const visible = body === 'true'; + return isElementVisible(root as Element) === visible ? [root as Element] : []; }; return { queryAll }; } diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 384bf4aef1..605ff6c5a0 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -74,6 +74,18 @@ it('should work with >> visible=', async ({ page }) => { expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2'); }); +it('should work with >> visible=false', async ({ page }) => { + await page.setContent(` +
+
+
+
+ `); + await expect(page.locator('div >> visible=false')).toHaveCount(2); + await page.locator('#target2').evaluate(div => div.textContent = 'Now visible'); + await expect(page.locator('div >> visible=false')).toHaveCount(1); +}); + it('should work with :nth-match', async ({ page }) => { await page.setContent(`
From 7ce1a540bcd979096dc4ac3851c675407ede6f5a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 16 Dec 2024 15:28:21 -0800 Subject: [PATCH 16/73] chore(bidi): skip only timeouts on CI (#34041) --- tests/bidi/expectationUtil.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bidi/expectationUtil.ts b/tests/bidi/expectationUtil.ts index cdf9b779f2..a9f093c46f 100644 --- a/tests/bidi/expectationUtil.ts +++ b/tests/bidi/expectationUtil.ts @@ -29,7 +29,7 @@ export async function createSkipTestPredicate(projectName: string): Promise { const key = info.titlePath.join(' › '); const expectation = expectationsMap.get(key); - return expectation === 'fail' || expectation === 'timeout'; + return expectation === 'timeout'; }; } From aabbcbf41de07b563a17410cf2ec03c7eeeacfaa Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 17 Dec 2024 05:24:22 -0800 Subject: [PATCH 17/73] fix(trace-viewer): Fix network log flicker #33929 (#34036) --- packages/web/src/uiUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index ea71486014..3544ec4bdc 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -43,6 +43,11 @@ export function useMeasure() { const target = ref.current; if (!target) return; + + const bounds = target.getBoundingClientRect(); + + setMeasure(new DOMRect(0, 0, bounds.width, bounds.height)); + const resizeObserver = new ResizeObserver((entries: any) => { const entry = entries[entries.length - 1]; if (entry && entry.contentRect) From 7ed60ccf7f34fbe363b892d463c5e0184e921a2a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 17 Dec 2024 11:17:22 -0800 Subject: [PATCH 18/73] feat(test): step.fail and step.fixme modifiers (#34042) --- docs/src/test-api/class-test.md | 106 +++++++++++ packages/playwright/src/common/testType.ts | 36 +++- packages/playwright/types/test.d.ts | 212 ++++++++++++++++++++- tests/playwright-test/test-step.spec.ts | 100 ++++++++++ tests/playwright-test/types-2.spec.ts | 23 +++ utils/generate_types/overrides-test.d.ts | 6 +- 6 files changed, 473 insertions(+), 10 deletions(-) diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 77a11c073f..d6f1d87513 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). +## async method: Test.step.fail +* since: v1.50 +- returns: <[void]> + +Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + +:::note +If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected. +::: + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fail('currently failing', async () => { + // ... + }); +}); +``` + +### param: Test.step.fail.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fail.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fail.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fail.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fail.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + +## async method: Test.step.fixme +* since: v1.50 +- returns: <[void]> + +Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fixme('not yet ready', async () => { + // ... + }); +}); +``` + +### param: Test.step.fixme.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fixme.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fixme.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fixme.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fixme.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + ## method: Test.use * since: v1.10 diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index d3c2f1c23a..61f9b36824 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -56,7 +56,9 @@ export class TestTypeImpl { test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only')); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this)); - test.step = this._step.bind(this); + test.step = this._step.bind(this, 'pass'); + test.step.fail = this._step.bind(this, 'fail'); + test.step.fixme = this._step.bind(this, 'fixme'); test.use = wrapFunctionWithLocation(this._use.bind(this)); test.extend = wrapFunctionWithLocation(this._extend.bind(this)); test.info = () => { @@ -257,22 +259,40 @@ export class TestTypeImpl { suite._use.push({ fixtures, location }); } - async _step(title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { + async _step(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); + if (expectation === 'fixme') + return undefined as T; const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); return await zones.run('stepZone', step, async () => { + let result; + let error; try { - const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); - if (result.timedOut) - throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); - step.complete({}); - return result.result; - } catch (error) { + result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); + } catch (e) { + error = e; + } + if (result?.timedOut) { + const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); step.complete({ error }); throw error; } + const expectedToFail = expectation === 'fail'; + if (error) { + step.complete({ error }); + if (expectedToFail) + return undefined as T; + throw error; + } + if (expectedToFail) { + error = new Error(`Step is expected to fail, but passed`); + step.complete({ error }); + throw error; + } + step.complete({}); + return result!.result; }); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 520bcb30d3..caed95b8d5 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5551,7 +5551,217 @@ export interface TestType { * @param body Step body. * @param options */ - step(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + /** + * Declares a test step that is shown in the report. + * + * **Usage** + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * await test.step('Log in', async () => { + * // ... + * }); + * + * await test.step('Outer step', async () => { + * // ... + * // You can nest steps inside each other. + * await test.step('Inner step', async () => { + * // ... + * }); + * }); + * }); + * ``` + * + * **Details** + * + * The method returns the value returned by the step callback. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * const user = await test.step('Log in', async () => { + * // ... + * return 'john'; + * }); + * expect(user).toBe('john'); + * }); + * ``` + * + * **Decorator** + * + * You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show + * up as a step in the report. + * + * ```js + * function step(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }); + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @step + * async login() { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await this.page.getByLabel('Username or email address').fill(account.username); + * await this.page.getByLabel('Password').fill(account.password); + * await this.page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); + * }); + * ``` + * + * **Boxing** + * + * When something inside a step fails, you would usually see the error pointing to the exact action that failed. For + * example, consider the following login step: + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await page.getByLabel('Username or email address').fill(account.username); + * await page.getByLabel('Password').fill(account.password); + * await page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * }); + * } + * + * test('example', async ({ page }) => { + * await page.goto('https://github.com/login'); + * await login(page); + * }); + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 8 | await page.getByRole('button', { name: 'Sign in' }).click(); + * > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * | ^ + * 10 | }); + * ``` + * + * As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight + * the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step + * call site. + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * // ... + * }, { box: true }); // Note the "box" option here. + * } + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 14 | await page.goto('https://github.com/login'); + * > 15 | await login(page); + * | ^ + * 16 | }); + * ``` + * + * You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above: + * + * ```js + * function boxedStep(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }, { box: true }); // Note the "box" option here. + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @boxedStep + * async login() { + * // .... + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); // <-- Error will be reported on this line. + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fixme('not yet ready', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is + * useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + * + * **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is + * thrown. This indicates the step did not fail as expected. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fail('currently failing', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } /** * `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions). * diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index ac6845eeae..74448ccbf8 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -1494,3 +1494,103 @@ fixture | fixture: context `); }); +test('test.step.fail and test.step.fixme should work', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ }) => { + await test.step('outer step 1', async () => { + await test.step.fail('inner step 1.1', async () => { + throw new Error('inner step 1.1 failed'); + }); + await test.step.fixme('inner step 1.2', async () => {}); + await test.step('inner step 1.3', async () => {}); + }); + await test.step('outer step 2', async () => { + await test.step.fixme('inner step 2.1', async () => {}); + await test.step('inner step 2.2', async () => { + expect(1).toBe(1); + }); + }); + await test.step.fail('outer step 3', async () => { + throw new Error('outer step 3 failed'); + }); + }); + ` + }, { reporter: '' }); + + expect(result.exitCode).toBe(0); + expect(result.report.stats.expected).toBe(1); + expect(result.report.stats.unexpected).toBe(0); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 1 @ a.test.ts:4 +test.step | inner step 1.1 @ a.test.ts:5 +test.step | ↪ error: Error: inner step 1.1 failed +test.step | inner step 1.3 @ a.test.ts:9 +test.step |outer step 2 @ a.test.ts:11 +test.step | inner step 2.2 @ a.test.ts:13 +expect | expect.toBe @ a.test.ts:14 +test.step |outer step 3 @ a.test.ts:17 +test.step |↪ error: Error: outer step 3 failed +hook |After Hooks +`); +}); + +test('timeout inside test.step.fail is an error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test 2', async ({ }) => { + await test.step('outer step 2', async () => { + await test.step.fail('inner step 2', async () => { + await new Promise(() => {}); + }); + }); + }); + ` + }, { reporter: '', timeout: 2500 }); + + expect(result.exitCode).toBe(1); + expect(result.report.stats.unexpected).toBe(1); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 2 @ a.test.ts:4 +test.step | inner step 2 @ a.test.ts:5 +hook |After Hooks +hook |Worker Cleanup + |Test timeout of 2500ms exceeded. +`); +}); + +test('skip test.step.fixme body', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ }) => { + let didRun = false; + await test.step('outer step 2', async () => { + await test.step.fixme('inner step 2', async () => { + didRun = true; + }); + }); + expect(didRun).toBe(false); + }); + ` + }, { reporter: '' }); + + expect(result.exitCode).toBe(0); + expect(result.report.stats.expected).toBe(1); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 2 @ a.test.ts:5 +expect |expect.toBe @ a.test.ts:10 +hook |After Hooks +`); +}); diff --git a/tests/playwright-test/types-2.spec.ts b/tests/playwright-test/types-2.spec.ts index f794e06798..3a06ed0da2 100644 --- a/tests/playwright-test/types-2.spec.ts +++ b/tests/playwright-test/types-2.spec.ts @@ -204,3 +204,26 @@ test('step should inherit return type from its callback ', async ({ runTSC }) => }); expect(result.exitCode).toBe(0); }); + +test('step.fail and step.fixme return void ', async ({ runTSC }) => { + const result = await runTSC({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test step.fail', async ({ }) => { + // @ts-expect-error + const bad1: string = await test.step.fail('my step', () => { }); + const good: void = await test.step.fail('my step', async () => { + return 2024; + }); + }); + test('test step.fixme', async ({ }) => { + // @ts-expect-error + const bad1: string = await test.step.fixme('my step', () => { }); + const good: void = await test.step.fixme('my step', async () => { + return 2024; + }); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 49c6988ea7..3370103a25 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -162,7 +162,11 @@ export interface TestType { afterAll(inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise | any): void; afterAll(title: string, inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise | any): void; use(fixtures: Fixtures<{}, {}, TestArgs, WorkerArgs>): void; - step(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } expect: Expect<{}>; extend(fixtures: Fixtures): TestType; info(): TestInfo; From 43e46d63ddb6415c9c791efea93780ef9abc4853 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 17 Dec 2024 20:26:56 +0100 Subject: [PATCH 19/73] chore: use recorder/html types for exported shared types (#34056) --- packages/html-reporter/src/{types.ts => types.d.ts} | 0 packages/recorder/src/{actions.ts => actions.d.ts} | 0 packages/recorder/src/{recorderTypes.ts => recorderTypes.d.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/html-reporter/src/{types.ts => types.d.ts} (100%) rename packages/recorder/src/{actions.ts => actions.d.ts} (100%) rename packages/recorder/src/{recorderTypes.ts => recorderTypes.d.ts} (100%) diff --git a/packages/html-reporter/src/types.ts b/packages/html-reporter/src/types.d.ts similarity index 100% rename from packages/html-reporter/src/types.ts rename to packages/html-reporter/src/types.d.ts diff --git a/packages/recorder/src/actions.ts b/packages/recorder/src/actions.d.ts similarity index 100% rename from packages/recorder/src/actions.ts rename to packages/recorder/src/actions.d.ts diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.d.ts similarity index 100% rename from packages/recorder/src/recorderTypes.ts rename to packages/recorder/src/recorderTypes.d.ts From 52b2548612a3efc3e53f27b7d72f8a37384e7386 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 17 Dec 2024 20:27:21 +0100 Subject: [PATCH 20/73] chore: no @web imports from @web package (#34055) --- packages/web/src/components/errorMessage.tsx | 2 +- packages/web/src/components/gridView.tsx | 2 +- packages/web/src/components/listView.tsx | 2 +- packages/web/src/components/tabbedPane.tsx | 2 +- packages/web/src/components/toolbar.tsx | 2 +- packages/web/src/components/toolbarButton.tsx | 2 +- packages/web/src/components/treeView.tsx | 2 +- packages/web/src/components/xtermWrapper.tsx | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/errorMessage.tsx b/packages/web/src/components/errorMessage.tsx index c9f4500ece..a37f28e2ec 100644 --- a/packages/web/src/components/errorMessage.tsx +++ b/packages/web/src/components/errorMessage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ansi2html } from '@web/ansi2html'; +import { ansi2html } from '../ansi2html'; import * as React from 'react'; import './errorMessage.css'; diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 10fc48c247..303de4b8d3 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { ListView } from './listView'; import type { ListViewProps } from './listView'; import './gridView.css'; -import { ResizeView } from '@web/shared/resizeView'; +import { ResizeView } from '../shared/resizeView'; export type Sorting = { by: keyof T, negate: boolean }; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 73f9b65b8f..079936c4a1 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import './listView.css'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; export type ListViewProps = { name: string, diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index fca5852110..2f5966cc51 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './tabbedPane.css'; import { Toolbar } from './toolbar'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index a81b834f4a..32a4227d7f 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './toolbar.css'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 2cdd85b9b7..521951bfee 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -17,7 +17,7 @@ import './toolbarButton.css'; import '../third_party/vscode/codicon.css'; import * as React from 'react'; -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; export interface ToolbarButtonProps { title: string, diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index 14f2e35195..f478ba4025 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; import './treeView.css'; export type TreeItem = { diff --git a/packages/web/src/components/xtermWrapper.tsx b/packages/web/src/components/xtermWrapper.tsx index 70a3e47114..9293c558d0 100644 --- a/packages/web/src/components/xtermWrapper.tsx +++ b/packages/web/src/components/xtermWrapper.tsx @@ -19,8 +19,8 @@ import './xtermWrapper.css'; import type { ITheme, Terminal } from 'xterm'; import type { FitAddon } from 'xterm-addon-fit'; import type { XtermModule } from './xtermModule'; -import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme'; -import { useMeasure } from '@web/uiUtils'; +import { currentTheme, addThemeListener, removeThemeListener } from '../theme'; +import { useMeasure } from '../uiUtils'; export type XtermDataSource = { pending: (string | Uint8Array)[]; From c9ae644e5f12b1947846282c6a4887233415044c Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 17 Dec 2024 13:31:01 -0800 Subject: [PATCH 21/73] feat(chromium-tip-of-tree): roll to r1287 (#34057) --- 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 a4d1786903..153b02dd6b 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1286", + "revision": "1287", "installByDefault": false, - "browserVersion": "133.0.6891.0" + "browserVersion": "133.0.6901.0" }, { "name": "firefox", From 443b2a2bbc51b6ea587f93975333e377af2248e2 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 18 Dec 2024 11:41:48 +0100 Subject: [PATCH 22/73] fix: don't rely on requestAnimationFrame (#34065) --- packages/html-reporter/src/links.tsx | 2 +- tests/playwright-test/reporter-html.spec.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index a6ea1e6695..b8db4c0e9e 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -143,7 +143,7 @@ export function useIsAnchored(id: AnchorID) { export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) { const ref = React.useRef(null); const onAnchorReveal = React.useCallback(() => { - requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' })); + ref.current?.scrollIntoView({ block: 'start', inline: 'start' }); }, []); useAnchor(id, onAnchorReveal); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 5568455d63..556d12e8a2 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -936,6 +936,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(attachment).not.toBeInViewport(); await page.getByLabel('attach "foo-2"').getByTitle('link to attachment').click(); await expect(attachment).toBeInViewport(); + + await page.reload(); + await expect(attachment).toBeInViewport(); }); test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => { From 67bc484d8b29e5123676b191de902d626974cc1b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 18 Dec 2024 12:39:08 +0100 Subject: [PATCH 23/73] chore(ui): test that UI works behind proxy, take 2 (#33771) --- tests/config/proxy.ts | 23 +++++++++++++-- tests/playwright-test/ui-mode-trace.spec.ts | 32 +++++++++++++++++++++ tests/third_party/proxy/index.ts | 27 +++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/tests/config/proxy.ts b/tests/config/proxy.ts index 0e71e47790..7efe389b12 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -32,6 +32,7 @@ export class TestProxy { connectHosts: string[] = []; requestUrls: string[] = []; + wsUrls: string[] = []; private readonly _server: ProxyServer; private readonly _sockets = new Set(); @@ -58,11 +59,16 @@ export class TestProxy { await new Promise(x => this._server.close(x)); } - forwardTo(port: number, options?: { allowConnectRequests: boolean }) { + forwardTo(port: number, options?: { allowConnectRequests?: boolean, prefix?: string, preserveHostname?: boolean }) { this._prependHandler('request', (req: IncomingMessage) => { this.requestUrls.push(req.url); - const url = new URL(req.url); - url.host = `127.0.0.1:${port}`; + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); req.url = url.toString(); }); this._prependHandler('connect', (req: IncomingMessage) => { @@ -73,6 +79,17 @@ export class TestProxy { this.connectHosts.push(req.url); req.url = `127.0.0.1:${port}`; }); + this._prependHandler('upgrade', (req: IncomingMessage) => { + this.wsUrls.push(req.url); + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); + req.url = url.toString(); + }); } setAuthHandler(handler: (req: IncomingMessage) => boolean) { diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index ef7c8fcf65..06cff62399 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -340,6 +340,38 @@ test('should show request source context id', async ({ runUITest, server }) => { await expect(page.getByText('api#1')).toBeVisible(); }); +test('should work behind reverse proxy', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33705' } }, async ({ runUITest, proxyServer: reverseProxy }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({ page }) => { + await page.setContent(''); + await page.getByRole('button').click(); + expect(1).toBe(1); + }); + `, + }); + + const uiModeUrl = new URL(page.url()); + reverseProxy.forwardTo(+uiModeUrl.port, { prefix: '/subdir', preserveHostname: true }); + await page.goto(`${reverseProxy.URL}/subdir${uiModeUrl.pathname}?${uiModeUrl.searchParams}`); + + await page.getByText('trace test').dblclick(); + + await expect(page.getByTestId('actions-tree')).toMatchAriaSnapshot(` + - tree: + - treeitem /Before Hooks \\d+[hmsp]+/ + - treeitem /page\\.setContent \\d+[hmsp]+/ + - treeitem /locator\\.clickgetByRole\\('button'\\) \\d+[hmsp]+/ + - treeitem /expect\\.toBe \\d+[hmsp]+/ [selected] + - treeitem /After Hooks \\d+[hmsp]+/ + `); + + await expect( + page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('button'), + ).toHaveText('Submit'); +}); + test('should filter actions tab on double-click', async ({ runUITest, server }) => { const { page } = await runUITest({ 'a.spec.ts': ` diff --git a/tests/third_party/proxy/index.ts b/tests/third_party/proxy/index.ts index 32f3d73437..6fbd3e9407 100644 --- a/tests/third_party/proxy/index.ts +++ b/tests/third_party/proxy/index.ts @@ -3,6 +3,7 @@ import * as net from 'net'; import * as url from 'url'; import * as http from 'http'; import * as os from 'os'; +import { pipeline } from 'stream/promises'; const pkg = { version: '1.0.0' } @@ -33,6 +34,7 @@ export function createProxy(server?: http.Server): ProxyServer { if (!server) server = http.createServer(); server.on('request', onrequest); server.on('connect', onconnect); + server.on('upgrade', onupgrade); return server; } @@ -465,4 +467,29 @@ function requestAuthorization( }; res.writeHead(407, headers); res.end('Proxy authorization required'); +} + +function onupgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + const proxyReq = http.request(req.url, { + method: req.method, + headers: req.headers, + localAddress: this.localAddress, + }); + + proxyReq.on('upgrade', async function (proxyRes, proxySocket, proxyHead) { + const header = ['HTTP/1.1 101 Switching Protocols']; + for (const [key, value] of Object.entries(proxyRes.headersDistinct)) + header.push(`${key}: ${value}`); + socket.write(header.join('\r\n') + '\r\n\r\n'); + if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead); + + try { + await pipeline(proxySocket, socket, proxySocket); + } catch (error) { + if (error.code !== "ECONNRESET") + throw error; + } + }); + + proxyReq.end(head); } \ No newline at end of file From c57155e30c74d553651bbd08414f84ba5a650933 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Wed, 18 Dec 2024 13:16:03 -0800 Subject: [PATCH 24/73] docs: explain globalSetup caveats for reporters (#34063) --- docs/src/test-global-setup-teardown-js.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/test-global-setup-teardown-js.md b/docs/src/test-global-setup-teardown-js.md index 883bdf25d6..04617979ea 100644 --- a/docs/src/test-global-setup-teardown-js.md +++ b/docs/src/test-global-setup-teardown-js.md @@ -129,7 +129,13 @@ You can use the `globalSetup` option in the [configuration file](./test-configur Similarly, use `globalTeardown` to run something once after all the tests. Alternatively, let `globalSetup` return a function that will be used as a global teardown. You can pass data such as port number, authentication tokens, etc. from your global setup to your tests using environment variables. :::note -Using `globalSetup` and `globalTeardown` will not produce traces or artifacts, and options like `headless` or `testIdAttribute` specified in the config file are not applied. If you want to produce traces and artifacts and respect config options, use [project dependencies](#option-1-project-dependencies). +Beware of `globalSetup` and `globalTeardown` caveats: + +- These methods will not produce traces or artifacts unless explictly enabled, as described in [Capturing trace of failures during global setup](#capturing-trace-of-failures-during-global-setup). +- Options sush as `headless` or `testIdAttribute` specified in the config file are not applied, +- An uncaught exception thrown in `globalSetup` will prevent Playwright from running tests, and no test results will appear in reporters. + +Consider using [project dependencies](#option-1-project-dependencies) to produce traces, artifacts, respect config options and get test results in reporters even in case of a setup failure. ::: ```js title="playwright.config.ts" From f7c99ee6e3bd63f1951b57f226051d0601adda8f Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 18 Dec 2024 22:26:01 +0100 Subject: [PATCH 25/73] chore: update CDN endpoints (#34061) --- packages/playwright-core/src/server/registry/index.ts | 8 ++------ tests/installation/playwright-cdn.spec.ts | 6 ++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 4bb27bcaea..f12d5ceb4c 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -37,13 +37,9 @@ const PACKAGE_PATH = path.join(__dirname, '..', '..', '..'); const BIN_PATH = path.join(__dirname, '..', '..', '..', 'bin'); const PLAYWRIGHT_CDN_MIRRORS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP CDN + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP CDN 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // Directly hit ESRP CDN - - // Old endpoints which hit the Storage Bucket directly: - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', // Actually Edgio which will be retired Q4 2025. - 'https://playwright-verizon.azureedge.net', // Actually Edgio which will be retired Q4 2025. + 'https://cdn.playwright.dev', // Hit the Storage Bucket directly ]; if (process.env.PW_TEST_CDN_THAT_SHOULD_WORK) { diff --git a/tests/installation/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index af0339f03b..3b472625e9 100644 --- a/tests/installation/playwright-cdn.spec.ts +++ b/tests/installation/playwright-cdn.spec.ts @@ -19,11 +19,9 @@ import net from 'net'; import type { AddressInfo } from 'net'; const CDNS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // ESRP Fallback - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', - 'https://playwright-verizon.azureedge.net', + 'https://cdn.playwright.dev', ]; const DL_STAT_BLOCK = /^.*from url: (.*)$\n^.*to location: (.*)$\n^.*response status code: (.*)$\n^.*total bytes: (\d+)$\n^.*download complete, size: (\d+)$\n^.*SUCCESS downloading (\w+) .*$/gm; From d9e5ca06bf86c82fd9d5c2c244cc64c974c371ed Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 18 Dec 2024 21:32:16 +0000 Subject: [PATCH 26/73] fix(remote server): allow local paths in extension mode (#34051) --- .../playwright-core/src/remote/playwrightConnection.ts | 8 +++++--- packages/playwright-core/src/remote/playwrightServer.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index cce31207a8..ea607bdb17 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -32,6 +32,7 @@ import { debugLogger } from '../utils/debugLogger'; export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android'; type Options = { + allowFSPaths: boolean, socksProxyPattern: string | undefined, browserName: string | null, launchOptions: LaunchOptions, @@ -60,7 +61,7 @@ export class PlaywrightConnection { this._ws = ws; this._preLaunched = preLaunched; this._options = options; - options.launchOptions = filterLaunchOptions(options.launchOptions); + options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') assert(preLaunched.playwright); if (clientType === 'pre-launched-browser-or-android') @@ -284,7 +285,7 @@ function launchOptionsHash(options: LaunchOptions) { return JSON.stringify(copy); } -function filterLaunchOptions(options: LaunchOptions): LaunchOptions { +function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { return { channel: options.channel, args: options.args, @@ -296,7 +297,8 @@ function filterLaunchOptions(options: LaunchOptions): LaunchOptions { chromiumSandbox: options.chromiumSandbox, firefoxUserPrefs: options.firefoxUserPrefs, slowMo: options.slowMo, - executablePath: isUnderTest() ? options.executablePath : undefined, + executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, + downloadsPath: allowFSPaths ? options.downloadsPath : undefined, }; } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 121cc2d83a..5cd99285ed 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -102,7 +102,7 @@ export class PlaywrightServer { return new PlaywrightConnection( semaphore.acquire(), clientType, ws, - { socksProxyPattern: proxyValue, browserName, launchOptions }, + { socksProxyPattern: proxyValue, browserName, launchOptions, allowFSPaths: this._options.mode === 'extension' }, { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, From c2d057ba23d902148ceb66b5aa87e16d0f699dff Mon Sep 17 00:00:00 2001 From: Rui Figueira Date: Wed, 18 Dec 2024 21:34:06 +0000 Subject: [PATCH 27/73] chore: add url option to routeFromHAR call if codegen launched with --save-har-glob (#34048) --- .../src/server/codegen/csharp.ts | 12 +++++--- .../src/server/codegen/java.ts | 18 ++++++++--- .../src/server/codegen/javascript.ts | 6 ++-- .../src/server/codegen/python.ts | 15 +++++----- .../inspector/cli-codegen-csharp.spec.ts | 30 ++++++++++++++++++- .../inspector/cli-codegen-java.spec.ts | 20 +++++++++++-- .../inspector/cli-codegen-pytest.spec.ts | 22 ++++++++++++++ .../cli-codegen-python-async.spec.ts | 13 +++++++- .../inspector/cli-codegen-python.spec.ts | 22 ++++++++++++++ .../inspector/cli-codegen-test.spec.ts | 15 ++++++++++ 10 files changed, 151 insertions(+), 22 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f9166c2a91..548a06343a 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -171,8 +171,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')}); var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } formatter.newLine(); return formatter.format(); } @@ -198,8 +200,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}] public async Task MyTest() {`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await Context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 1fafa0642c..ac04783c23 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -150,28 +150,38 @@ export class JavaLanguageGenerator implements LanguageGenerator { import com.microsoft.playwright.Page; import com.microsoft.playwright.options.*; - import org.junit.jupiter.api.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import org.junit.jupiter.api.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.*; @UsePlaywright public class TestExample { @Test void test(Page page) {`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new Page.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` page.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } formatter.add(` import com.microsoft.playwright.*; import com.microsoft.playwright.options.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; - import java.util.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import java.util.*; public class Example { public static void main(String[] args) { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)}); BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new BrowserContext.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` context.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index e5f72ce122..80cb10926b 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -147,8 +147,10 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test'; ${useText ? '\ntest.use(' + useText + ');\n' : ''} test('test', async ({ page }) => {`); - if (options.contextOptions.recordHar) - formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatOptions({ url }, false)}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 8d4ea7659d..714265a25c 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -137,6 +137,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { generateHeader(options: LanguageGeneratorOptions): string { const formatter = new PythonFormatter(); + const recordHar = options.contextOptions.recordHar; if (this._isPyTest) { const contextOptions = formatContextOptions(options.contextOptions, options.deviceName, true /* asDict */); const fixture = contextOptions ? ` @@ -146,13 +147,13 @@ def browser_context_args(browser_context_args, playwright) { return {${contextOptions}} } ` : ''; - formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re + formatter.add(`${options.deviceName || contextOptions ? 'import pytest\n' : ''}import re from playwright.sync_api import Page, expect ${fixture} def test_example(page: Page) -> None {`); - if (options.contextOptions.recordHar) - formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` page.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else if (this._isAsync) { formatter.add(` import asyncio @@ -163,8 +164,8 @@ from playwright.async_api import Playwright, async_playwright, expect async def run(playwright: Playwright) -> None { browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` await context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else { formatter.add(` import re @@ -174,8 +175,8 @@ from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None { browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } return formatter.format(); } diff --git a/tests/library/inspector/cli-codegen-csharp.spec.ts b/tests/library/inspector/cli-codegen-csharp.spec.ts index ffa32ceaf5..8b79f23b2a 100644 --- a/tests/library/inspector/cli-codegen-csharp.spec.ts +++ b/tests/library/inspector/cli-codegen-csharp.spec.ts @@ -179,6 +179,20 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { expect(json.log.creator.name).toBe('Playwright'); }); +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions +{ + Url = "**/*.js", +});`; + const cli = runCLI(['--target=csharp', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + for (const testFramework of ['nunit', 'mstest'] as const) { test(`should not print context options method override in ${testFramework} if no options were passed`, async ({ runCLI }) => { const cli = runCLI([`--target=csharp-${testFramework}`, emptyHTML]); @@ -201,7 +215,7 @@ for (const testFramework of ['nunit', 'mstest'] as const) { test(`should work with --save-har in ${testFramework}`, async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -209,6 +223,20 @@ for (const testFramework of ['nunit', 'mstest'] as const) { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + + test(`should work with --save-har and --save-har-glob in ${testFramework}`, async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions + { + Url = "**/*.js", + });`; + const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); + }); } test(`should print a valid basic program in mstest`, async ({ runCLI }) => { diff --git a/tests/library/inspector/cli-codegen-java.spec.ts b/tests/library/inspector/cli-codegen-java.spec.ts index 93f55132ed..2fd4a8fd42 100644 --- a/tests/library/inspector/cli-codegen-java.spec.ts +++ b/tests/library/inspector/cli-codegen-java.spec.ts @@ -89,10 +89,24 @@ test('should print load/save storage_state', async ({ runCLI, browserName }, tes await cli.waitFor(expectedResult2); }); -test('should work with --save-har', async ({ runCLI }, testInfo) => { +test('should work with --save-har and --save-har-glob as java-library', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `context.routeFromHAR(${JSON.stringify(harFileName)});`; - const cli = runCLI(['--target=java', `--save-har=${harFileName}`], { + const expectedResult = `context.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new BrowserContext.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob as java-junit', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new Page.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java-junit', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { autoExitWhen: expectedResult, }); diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index e1bd608ef5..ce9cd97ee0 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -69,3 +69,25 @@ def test_example(page: Page) -> None: page.goto("${emptyHTML}") `); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python-async.spec.ts b/tests/library/inspector/cli-codegen-python-async.spec.ts index 3c04a5d8fa..94cc947ae4 100644 --- a/tests/library/inspector/cli-codegen-python-async.spec.ts +++ b/tests/library/inspector/cli-codegen-python-async.spec.ts @@ -146,7 +146,7 @@ asyncio.run(main()) test('should work with --save-har', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await page.route_from_har(${JSON.stringify(harFileName)})`; + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)})`; const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -154,3 +154,14 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python.spec.ts b/tests/library/inspector/cli-codegen-python.spec.ts index 2bccbf3d93..13898efb5f 100644 --- a/tests/library/inspector/cli-codegen-python.spec.ts +++ b/tests/library/inspector/cli-codegen-python.spec.ts @@ -129,3 +129,25 @@ with sync_playwright() as playwright: `; await cli.waitFor(expectedResult2); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-test.spec.ts b/tests/library/inspector/cli-codegen-test.spec.ts index d76caee422..ad68e75c48 100644 --- a/tests/library/inspector/cli-codegen-test.spec.ts +++ b/tests/library/inspector/cli-codegen-test.spec.ts @@ -108,3 +108,18 @@ test('should generate routeFromHAR with --save-har', async ({ runCLI }, testInfo const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should generate routeFromHAR with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `test('test', async ({ page }) => { + await page.routeFromHAR('${harFileName.replace(/\\/g, '\\\\')}', { + url: '**/*.js' + }); +});`; + const cli = runCLI(['--target=playwright-test', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); \ No newline at end of file From bddbf8950e1c78dcdf072e1a57aaae797db496b3 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:08:27 -0800 Subject: [PATCH 28/73] feat(webkit): roll to r2120 (#34069) --- 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 153b02dd6b..525842d8ac 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2119", + "revision": "2120", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From 4c9a116aff04a3e775253d196453c4afaeeedf41 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 19 Dec 2024 00:23:35 +0100 Subject: [PATCH 29/73] chore: move protocol to d.ts types only files (#34077) --- packages/protocol/src/{callMetadata.ts => callMetadata.d.ts} | 0 packages/protocol/src/{channels.ts => channels.d.ts} | 0 utils/generate_channels.js | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/protocol/src/{callMetadata.ts => callMetadata.d.ts} (100%) rename packages/protocol/src/{channels.ts => channels.d.ts} (100%) diff --git a/packages/protocol/src/callMetadata.ts b/packages/protocol/src/callMetadata.d.ts similarity index 100% rename from packages/protocol/src/callMetadata.ts rename to packages/protocol/src/callMetadata.d.ts diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.d.ts similarity index 100% rename from packages/protocol/src/channels.ts rename to packages/protocol/src/channels.d.ts diff --git a/utils/generate_channels.js b/utils/generate_channels.js index 9da221f97e..0bebcbba0c 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -362,7 +362,7 @@ function writeFile(filePath, content) { fs.writeFileSync(filePath, content, 'utf8'); } -writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.ts'), channels_ts.join('\n')); +writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.d.ts'), channels_ts.join('\n')); writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'debug.ts'), debug_ts.join('\n')); writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'validator.ts'), validator_ts.join('\n')); process.exit(hasChanges ? 1 : 0); From a239ab3048baae0a0dcc33b1024ce35002087207 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 19 Dec 2024 01:17:42 -0800 Subject: [PATCH 30/73] feat(ffmpeg): roll to r1011 (#34079) 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 525842d8ac..c9bab0dffa 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "ffmpeg", - "revision": "1010", + "revision": "1011", "installByDefault": true, "revisionOverrides": { "mac12": "1010", From 9c14cccc24db0a486e5fab3f3f4f4be887eb2d39 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 19 Dec 2024 08:17:29 -0800 Subject: [PATCH 31/73] feat(chromium-tip-of-tree): roll to r1288 (#34092) --- 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 c9bab0dffa..0b673fef08 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1287", + "revision": "1288", "installByDefault": false, - "browserVersion": "133.0.6901.0" + "browserVersion": "133.0.6905.0" }, { "name": "firefox", From 94ffbcb9c5634f9acc0fc6de178ef315ac08f4a1 Mon Sep 17 00:00:00 2001 From: Volodymyr Momot <86200333+mmtv-qa@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:36:02 +0200 Subject: [PATCH 32/73] feat(fetch/network): add generic to json method (#34091) --- packages/playwright-core/src/client/fetch.ts | 4 ++-- packages/playwright-core/src/client/network.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 58928532ac..2cfbbff14b 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -350,9 +350,9 @@ export class APIResponse implements api.APIResponse { return content.toString('utf8'); } - async json(): Promise { + async json(): Promise { const content = await this.text(); - return JSON.parse(content); + return JSON.parse(content) as T; } async [Symbol.asyncDispose]() { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index cb18681ccf..25007db199 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -718,9 +718,9 @@ export class Response extends ChannelOwner implements return content.toString('utf8'); } - async json(): Promise { + async json(): Promise { const content = await this.text(); - return JSON.parse(content); + return JSON.parse(content) as T; } request(): Request { From 8e721fac858053fd0e7e6087b3863b4bfcff5ff5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 19 Dec 2024 11:55:10 -0800 Subject: [PATCH 33/73] chore(bidi): no retries on CI (#34080) --- tests/bidi/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 39df88f537..adaef490ea 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -58,7 +58,7 @@ const config: Config Date: Thu, 19 Dec 2024 21:03:33 +0100 Subject: [PATCH 34/73] devops: run bidi tests for bidi changes (#34099) --- .github/workflows/tests_bidi.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 46b16aac7b..4fee45e34c 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -7,6 +7,7 @@ on: - main paths: - .github/workflows/tests_bidi.yml + - packages/playwright-core/src/server/bidi/* schedule: # Run every day at midnight - cron: '0 0 * * *' From 7d3a1885304f8ce0f0b313b30b2649fd69b3c889 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 19 Dec 2024 12:14:58 -0800 Subject: [PATCH 35/73] chore(ui): Clean up settings component for shared uses (#34090) --- packages/trace-viewer/src/ui/settingsView.css | 25 ++++++------ packages/trace-viewer/src/ui/settingsView.tsx | 38 ++++++++++++------- packages/trace-viewer/src/ui/uiModeView.tsx | 8 ++-- 3 files changed, 41 insertions(+), 30 deletions(-) diff --git a/packages/trace-viewer/src/ui/settingsView.css b/packages/trace-viewer/src/ui/settingsView.css index 3ac8597e35..f759f0b07a 100644 --- a/packages/trace-viewer/src/ui/settingsView.css +++ b/packages/trace-viewer/src/ui/settingsView.css @@ -16,24 +16,25 @@ .settings-view { flex: none; - margin-top: 4px; + padding: 4px 0px; + row-gap: 8px; + user-select: none; +} + +.settings-view .setting { + display: flex; + align-items: center; } .settings-view .setting label { - display: flex; - flex-direction: row; - align-items: center; - margin: 4px 2px; -} + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; -.settings-view .setting:first-of-type label { - margin-top: 2px; -} - -.settings-view .setting:last-of-type label { - margin-bottom: 2px; + cursor: pointer; } .settings-view .setting input { margin-right: 5px; + flex-shrink: 0; } diff --git a/packages/trace-viewer/src/ui/settingsView.tsx b/packages/trace-viewer/src/ui/settingsView.tsx index 0a4340b2b6..69439e773c 100644 --- a/packages/trace-viewer/src/ui/settingsView.tsx +++ b/packages/trace-viewer/src/ui/settingsView.tsx @@ -18,22 +18,32 @@ import * as React from 'react'; import './settingsView.css'; export type Setting = { - value: T, - set: (value: T) => void, - title: string + value: T; + set: (value: T) => void; + name: string; + title?: string; }; export const SettingsView: React.FunctionComponent<{ - settings: Setting[], + settings: Setting[]; }> = ({ settings }) => { - return
- {settings.map(({ value, set, title }) => { - return
- -
; - })} -
; + return ( +
+ {settings.map(({ value, set, name, title }) => { + const labelId = `setting-${name}`; + + return ( +
+ set(!value)} + /> + +
+ ); + })} +
+ ); }; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index aa15e5a0a5..6ae2fdbd8c 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -508,9 +508,9 @@ export const UIModeView: React.FC<{}> = ({
Testing Options
{testingOptionsVisible && } } setSettingsVisible(!settingsVisible)}> @@ -522,7 +522,7 @@ export const UIModeView: React.FC<{}> = ({
Settings
{settingsVisible && } } From edd789780ac473083f6fb0b8740faddc43b439ab Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Thu, 19 Dec 2024 21:26:01 +0100 Subject: [PATCH 36/73] WebDriver BiDi: "browsingContext.captureScreenshot" accepts quality from 0 to 1 (#34097) --- packages/playwright-core/src/server/bidi/bidiPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 9b501c5484..cf0662738b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -399,7 +399,7 @@ export class BidiPage implements PageDelegate { context: this._session.sessionId, format: { type: `image/${format === 'png' ? 'png' : 'jpeg'}`, - quality: quality || 80, + quality: quality ? quality / 100 : 0.8, }, origin: documentRect ? 'document' : 'viewport', clip: { From ec1d3313c3796504a967612206c42bfad0991e1b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 19 Dec 2024 12:46:39 -0800 Subject: [PATCH 37/73] Revert "feat(fetch/network): add generic to json method" (#34098) --- packages/playwright-core/src/client/fetch.ts | 4 ++-- packages/playwright-core/src/client/network.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 2cfbbff14b..58928532ac 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -350,9 +350,9 @@ export class APIResponse implements api.APIResponse { return content.toString('utf8'); } - async json(): Promise { + async json(): Promise { const content = await this.text(); - return JSON.parse(content) as T; + return JSON.parse(content); } async [Symbol.asyncDispose]() { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 25007db199..cb18681ccf 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -718,9 +718,9 @@ export class Response extends ChannelOwner implements return content.toString('utf8'); } - async json(): Promise { + async json(): Promise { const content = await this.text(); - return JSON.parse(content) as T; + return JSON.parse(content); } request(): Request { From 6505a3e34cc9e2dad8e55c6b5bc713710ded5d14 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 19 Dec 2024 12:46:54 -0800 Subject: [PATCH 38/73] fix(yaml): escape to disambiguate yaml arrays (#34096) --- packages/playwright-core/src/server/injected/yaml.ts | 4 ++++ tests/page/page-aria-snapshot.spec.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/playwright-core/src/server/injected/yaml.ts b/packages/playwright-core/src/server/injected/yaml.ts index 7daebc574a..a9365c15bd 100644 --- a/packages/playwright-core/src/server/injected/yaml.ts +++ b/packages/playwright-core/src/server/injected/yaml.ts @@ -82,6 +82,10 @@ function yamlStringNeedsQuotes(str: string): boolean { if (/[{}`]/.test(str)) return true; + // YAML array starts with [ + if (/^\[/.test(str)) + return true; + // Non-string types recognized by YAML if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase())) return true; diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 77dc45a01e..1dafe14fa7 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -482,6 +482,7 @@ it('should escape yaml text in text nodes', async ({ page }) => { {four} [five] +
[Select all]
`); await checkAndMatchSnapshot(page.locator('body'), ` @@ -504,6 +505,7 @@ it('should escape yaml text in text nodes', async ({ page }) => { - text: "} [" - link "five" - text: "]" + - text: "[Select all]" `); }); From 61ce37cd53c25ff8de23e8b379ee2e480d987471 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 19 Dec 2024 22:09:49 +0100 Subject: [PATCH 39/73] test: use checkInstalledSoftwareOnDisk for itest (#34103) --- tests/installation/npmTest.ts | 13 ++++--- tests/installation/npx-global.spec.ts | 12 +++--- tests/installation/playwright-cdn.spec.ts | 4 +- ...playwright-cli-install-should-work.spec.ts | 14 +++---- ...aywright-packages-install-behavior.spec.ts | 38 +++++++++---------- .../skip-browser-download.spec.ts | 4 +- 6 files changed, 44 insertions(+), 41 deletions(-) diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index ab2b9b2a88..4801f967e8 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -75,7 +75,7 @@ type NPMTestFixtures = { _auto: void; _browsersPath: string; tmpWorkspace: string; - installedSoftwareOnDisk: () => Promise; + checkInstalledSoftwareOnDisk: (browsers: string[]) => Promise; writeFiles: (nameToContents: Record) => Promise; exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise; tsc: (args: string) => Promise; @@ -146,10 +146,13 @@ export const test = _test await use(registry); await registry.shutdown(); }, - installedSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { - if (!isolateBrowsers) - throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); - await use(async () => fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.')))); + checkInstalledSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { + await use(async expected => { + if (!isolateBrowsers) + throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); + const actual = await fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.'))); + expect(new Set(actual)).toEqual(new Set(expected)); + }); }, exec: async ({ tmpWorkspace, _browsersPath, isolateBrowsers }, use, testInfo) => { await use(async (cmd: string, ...argsAndOrOptions: [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]) => { diff --git a/tests/installation/npx-global.spec.ts b/tests/installation/npx-global.spec.ts index 6a3da572ef..0df4d4829b 100755 --- a/tests/installation/npx-global.spec.ts +++ b/tests/installation/npx-global.spec.ts @@ -17,26 +17,26 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true, allowGlobalInstall: true }); -test('npx playwright --help should not download browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright --help should not download browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npx playwright --help'); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); }); -test('npx playwright codegen', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright codegen', async ({ exec, checkInstalledSoftwareOnDisk }) => { const stdio = await exec('npx playwright codegen', { expectToExitWithError: true }); expect(stdio).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(stdio).toContain(`Please run the following command to download new browsers`); }); -test('npx playwright install global', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright install global', async ({ exec, checkInstalledSoftwareOnDisk }) => { test.skip(process.platform === 'win32', 'isLikelyNpxGlobal() does not work in this setup on our bots'); const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); expect(result).not.toContain(`Please run the following command to download new browsers`); expect(result).toContain(`To avoid unexpected behavior, please install your dependencies first`); }); diff --git a/tests/installation/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index 3b472625e9..9b74345025 100644 --- a/tests/installation/playwright-cdn.spec.ts +++ b/tests/installation/playwright-cdn.spec.ts @@ -38,11 +38,11 @@ const parsedDownloads = (rawLogs: string) => { test.use({ isolateBrowsers: true }); for (const cdn of CDNS) { - test(`playwright cdn failover should work (${cdn})`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright cdn failover should work (${cdn})`, async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); const result = await exec('npx playwright install', { env: { PW_TEST_CDN_THAT_SHOULD_WORK: cdn, DEBUG: 'pw:install' } }); expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); const dls = parsedDownloads(result); for (const software of ['chromium', 'ffmpeg', 'firefox', 'webkit']) expect(dls).toContainEqual({ status: 200, name: software, url: expect.stringContaining(cdn) }); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index d1a8bd3d04..064568f3e2 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -19,19 +19,19 @@ import path from 'path'; test.use({ isolateBrowsers: true }); -test('install command should work', async ({ exec, installedSoftwareOnDisk }) => { +test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await test.step('playwright install chromium', async () => { const result = await exec('npx playwright install chromium'); expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg']); }); await test.step('playwright install', async () => { const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); }); await exec('node sanity.js playwright', { env: { PLAYWRIGHT_BROWSERS_PATH: '0' } }); @@ -48,12 +48,12 @@ test('install command should work', async ({ exec, installedSoftwareOnDisk }) => } }); -test('should be able to remove browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('should be able to remove browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await exec('npx playwright install chromium'); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg']); await exec('npx playwright uninstall'); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); }); test('should print the right install command without browsers', async ({ exec }) => { @@ -91,7 +91,7 @@ test('subsequent installs works', async ({ exec }) => { await exec('node --unhandled-rejections=strict', path.join('node_modules', '@playwright', 'browser-chromium', 'install.js')); }); -test('install playwright-chromium should work', async ({ exec, installedSoftwareOnDisk }) => { +test('install playwright-chromium should work', async ({ exec }) => { await exec('npm i playwright-chromium'); await exec('npx playwright install chromium'); await exec('node sanity.js playwright-chromium chromium'); diff --git a/tests/installation/playwright-packages-install-behavior.spec.ts b/tests/installation/playwright-packages-install-behavior.spec.ts index 9975abce99..9ace1eaa67 100755 --- a/tests/installation/playwright-packages-install-behavior.spec.ts +++ b/tests/installation/playwright-packages-install-behavior.spec.ts @@ -19,7 +19,7 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`playwright-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `playwright-${browser}`; const result = await exec('npm i --foreground-scripts', pkg); const browserName = pkg.split('-')[1]; @@ -27,7 +27,7 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { if (browserName === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); expect(result).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); await exec('node sanity.js', pkg, browser); await exec('node', `esm-${pkg}.mjs`); @@ -35,7 +35,7 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { } for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`@playwright/browser-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`@playwright/browser-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `@playwright/browser-${browser}`; const expectedSoftware = [browser]; if (browser === 'chromium') @@ -43,67 +43,67 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { const result1 = await exec('npm i --foreground-scripts', pkg); expect(result1).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result1).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); const result2 = await exec('npm i --foreground-scripts playwright'); expect(result2).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); await exec('node sanity.js playwright', browser); await exec('node browser-only.js', pkg); }); } -test(`playwright-core should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright-core should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright-core'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const stdio = await exec('npx playwright-core', 'test', '-c', '.', { expectToExitWithError: true }); expect(stdio).toContain(`Please install @playwright/test package`); }); -test(`playwright should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install'); expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); await exec('node sanity.js playwright chromium firefox webkit'); await exec('node esm-playwright.mjs'); }); -test(`playwright should work with chromium --no-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --no-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install chromium --no-shell'); expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'ffmpeg']); }); -test(`playwright should work with chromium --only-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --only-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install --only-shell'); expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); }); -test('@playwright/test should work', async ({ exec, installedSoftwareOnDisk }) => { +test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts @playwright/test'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); await exec('npx playwright test -c . sample.spec.js', { expectToExitWithError: true, message: 'should not be able to run tests without installing browsers' }); const result2 = await exec('npx playwright install'); expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); await exec('node sanity.js @playwright/test chromium firefox webkit'); await exec('node', 'esm-playwright-test.mjs'); diff --git a/tests/installation/skip-browser-download.spec.ts b/tests/installation/skip-browser-download.spec.ts index 7cf9cdf602..21d4443dca 100755 --- a/tests/installation/skip-browser-download.spec.ts +++ b/tests/installation/skip-browser-download.spec.ts @@ -17,10 +17,10 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); -test('should skip browser installs', async ({ exec, installedSoftwareOnDisk }) => { +test('should skip browser installs', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npm i --foreground-scripts playwright @playwright/browser-firefox', { env: { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' } }); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).toContain(`Skipping browsers download because`); if (process.platform === 'linux') { From d7a52347e5190170a794ca561bae6d499a42b9fb Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 19 Dec 2024 14:04:05 -0800 Subject: [PATCH 40/73] chore(bidi): skip tooling tests (#34105) --- tests/bidi/playwright.config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index adaef490ea..481fa42434 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -66,7 +66,6 @@ const config: Config Date: Thu, 19 Dec 2024 23:29:21 +0100 Subject: [PATCH 41/73] chore: move winldd to CDN (#34078) --- packages/playwright-core/bin/PrintDeps.exe | Bin 275456 -> 0 bytes packages/playwright-core/bin/README.md | 2 - packages/playwright-core/browsers.json | 5 ++ packages/playwright-core/src/cli/program.ts | 3 + .../src/server/registry/dependencies.ts | 10 ++-- .../src/server/registry/index.ts | 56 +++++++++++++++++- tests/installation/npmTest.ts | 8 +-- tests/installation/playwright-cdn.spec.ts | 6 +- ...playwright-cli-install-should-work.spec.ts | 12 ++-- ...aywright-packages-install-behavior.spec.ts | 22 +++---- .../playwright-test-plugin.spec.ts | 1 - 11 files changed, 94 insertions(+), 31 deletions(-) delete mode 100644 packages/playwright-core/bin/PrintDeps.exe delete mode 100644 packages/playwright-core/bin/README.md diff --git a/packages/playwright-core/bin/PrintDeps.exe b/packages/playwright-core/bin/PrintDeps.exe deleted file mode 100644 index eb8ddf4d7b508ace4c40bd551fd821a05b92574f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 275456 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 diff --git a/packages/playwright-core/bin/README.md b/packages/playwright-core/bin/README.md deleted file mode 100644 index 2426643de5..0000000000 --- a/packages/playwright-core/bin/README.md +++ /dev/null @@ -1,2 +0,0 @@ -See building instructions at [`/browser_patches/winldd/README.md`](../../../browser_patches/winldd/README.md) - diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 0b673fef08..a163c7a607 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -52,6 +52,11 @@ "mac12-arm64": "1010" } }, + { + "name": "winldd", + "revision": "1007", + "installByDefault": false + }, { "name": "android", "revision": "1001", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 7ce1c4f928..5cd941d5d4 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -116,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on } } + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); + if (faultyArguments.length) throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`); return executables; diff --git a/packages/playwright-core/src/server/registry/dependencies.ts b/packages/playwright-core/src/server/registry/dependencies.ts index 44f56f3d2f..60ef80d846 100644 --- a/packages/playwright-core/src/server/registry/dependencies.ts +++ b/packages/playwright-core/src/server/registry/dependencies.ts @@ -21,7 +21,7 @@ import childProcess from 'child_process'; import * as utils from '../../utils'; import { spawnAsync } from '../../utils/spawnAsync'; import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform'; -import { buildPlaywrightCLICommand } from '.'; +import { buildPlaywrightCLICommand, registry } from '.'; import { deps } from './nativeDeps'; import { getPlaywrightVersion } from '../../utils/userAgent'; @@ -122,12 +122,12 @@ export async function installDependenciesLinux(targets: Set, dr }); } -export async function validateDependenciesWindows(windowsExeAndDllDirectories: string[]) { +export async function validateDependenciesWindows(sdkLanguage: string, windowsExeAndDllDirectories: string[]) { const directoryPaths = windowsExeAndDllDirectories; const lddPaths: string[] = []; for (const directoryPath of directoryPaths) lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); - const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(lddPath))); + const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(sdkLanguage, lddPath))); const missingDeps: Set = new Set(); for (const deps of allMissingDeps) { for (const dep of deps) @@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise> { - const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe'); +async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise> { + const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage); const dirname = path.dirname(filePath); const { stdout, code } = await spawnAsync(executable, [filePath], { cwd: dirname, diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index f12d5ceb4c..c4d7f2ffbb 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -79,6 +79,11 @@ const EXECUTABLE_PATHS = { 'mac': ['ffmpeg-mac'], 'win': ['ffmpeg-win64.exe'], }, + 'winldd': { + 'linux': undefined, + 'mac': undefined, + 'win': ['PrintDeps.exe'], + }, }; type DownloadPaths = Record; @@ -315,6 +320,35 @@ const DOWNLOAD_PATHS: Record = { 'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.zip', 'win64': 'builds/ffmpeg/%s/ffmpeg-win64.zip', }, + 'winldd': { + '': undefined, + 'ubuntu18.04-x64': undefined, + 'ubuntu20.04-x64': undefined, + 'ubuntu22.04-x64': undefined, + 'ubuntu24.04-x64': undefined, + 'ubuntu18.04-arm64': undefined, + 'ubuntu20.04-arm64': undefined, + 'ubuntu22.04-arm64': undefined, + 'ubuntu24.04-arm64': undefined, + 'debian11-x64': undefined, + 'debian11-arm64': undefined, + 'debian12-x64': undefined, + 'debian12-arm64': undefined, + 'mac10.13': undefined, + 'mac10.14': undefined, + 'mac10.15': undefined, + 'mac11': undefined, + 'mac11-arm64': undefined, + 'mac12': undefined, + 'mac12-arm64': undefined, + 'mac13': undefined, + 'mac13-arm64': undefined, + 'mac14': undefined, + 'mac14-arm64': undefined, + 'mac15': undefined, + 'mac15-arm64': undefined, + 'win64': 'builds/winldd/%s/winldd-win64.zip', + }, 'android': { '': 'builds/android/%s/android.zip', 'ubuntu18.04-x64': undefined, @@ -442,7 +476,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { } export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; -type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; +type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell']; @@ -772,6 +806,22 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); + const winldd = descriptors.find(d => d.name === 'winldd')!; + const winlddExecutable = findExecutablePath(winldd.dir, 'winldd'); + this._executables.push({ + type: 'tool', + name: 'winldd', + browserName: undefined, + directory: winldd.dir, + executablePath: () => winlddExecutable, + executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('winldd', winlddExecutable, winldd.installByDefault, sdkLanguage), + installType: process.platform === 'win32' ? 'download-by-default' : 'none', + _validateHostRequirements: () => Promise.resolve(), + downloadURLs: this._downloadURLs(winldd), + _install: () => this._downloadExecutable(winldd, winlddExecutable), + _dependencyGroup: 'tools', + _isHermeticInstallation: true, + }); const android = descriptors.find(d => d.name === 'android')!; this._executables.push({ type: 'tool', @@ -944,7 +994,7 @@ export class Registry { if (os.platform() === 'linux') return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries); if (os.platform() === 'win32' && os.arch() === 'x64') - return await validateDependenciesWindows(windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); + return await validateDependenciesWindows(sdkLanguage, windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); } async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) { @@ -1265,6 +1315,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) { return false; } const executables: Executable[] = []; + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); for (const browserName of browsers) { const executable = registry.findExecutable(browserName); if (!executable || executable.installType === 'none') diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index 4801f967e8..4e39fb8c98 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -31,22 +31,22 @@ export const TMP_WORKSPACES = path.join(os.platform() === 'darwin' ? '/tmp' : os const debug = debugLogger('itest'); const expect = _expect.extend({ - toHaveLoggedSoftwareDownload(received: any, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'ffmpeg')[]) { + toHaveLoggedSoftwareDownload(received: string, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'winldd' |'ffmpeg')[]) { if (typeof received !== 'string') throw new Error(`Expected argument to be a string.`); const downloaded = new Set(); let index = 0; while (true) { - const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); + const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|winldd|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); if (!match) break; downloaded.add(match[1].replace(/\s/g, '-').toLowerCase()); index += match.index + 1; } - const expected = browsers; - if (expected.length === downloaded.size && expected.every(browser => downloaded.has(browser))) { + const expected = new Set(browsers); + if (expected.size === downloaded.size && [...expected].every(browser => downloaded.has(browser))) { return { pass: true, message: () => 'Expected not to download browsers, but did.' diff --git a/tests/installation/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index 9b74345025..776aec42ad 100644 --- a/tests/installation/playwright-cdn.spec.ts +++ b/tests/installation/playwright-cdn.spec.ts @@ -37,12 +37,14 @@ const parsedDownloads = (rawLogs: string) => { test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const cdn of CDNS) { test(`playwright cdn failover should work (${cdn})`, async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); const result = await exec('npx playwright install', { env: { PW_TEST_CDN_THAT_SHOULD_WORK: cdn, DEBUG: 'pw:install' } }); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk((['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware])); const dls = parsedDownloads(result); for (const software of ['chromium', 'ffmpeg', 'firefox', 'webkit']) expect(dls).toContainEqual({ status: 200, name: software, url: expect.stringContaining(cdn) }); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index 064568f3e2..2825a9a44f 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -19,19 +19,21 @@ import path from 'path'; test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await test.step('playwright install chromium', async () => { const result = await exec('npx playwright install chromium'); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg']); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); }); await test.step('playwright install', async () => { const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['firefox', 'webkit']); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); await exec('node sanity.js playwright', { env: { PLAYWRIGHT_BROWSERS_PATH: '0' } }); @@ -51,9 +53,9 @@ test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk test('should be able to remove browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await exec('npx playwright install chromium'); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); await exec('npx playwright uninstall'); - await checkInstalledSoftwareOnDisk([]); + await checkInstalledSoftwareOnDisk([...extraInstalledSoftware]); }); test('should print the right install command without browsers', async ({ exec }) => { diff --git a/tests/installation/playwright-packages-install-behavior.spec.ts b/tests/installation/playwright-packages-install-behavior.spec.ts index 9ace1eaa67..2474d889eb 100755 --- a/tests/installation/playwright-packages-install-behavior.spec.ts +++ b/tests/installation/playwright-packages-install-behavior.spec.ts @@ -18,12 +18,14 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const browser of ['chromium', 'firefox', 'webkit']) { test(`playwright-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `playwright-${browser}`; const result = await exec('npm i --foreground-scripts', pkg); const browserName = pkg.split('-')[1]; - const expectedSoftware = [browserName]; + const expectedSoftware = [browserName, ...extraInstalledSoftware]; if (browserName === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); expect(result).toHaveLoggedSoftwareDownload(expectedSoftware as any); @@ -37,7 +39,7 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { for (const browser of ['chromium', 'firefox', 'webkit']) { test(`@playwright/browser-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `@playwright/browser-${browser}`; - const expectedSoftware = [browser]; + const expectedSoftware = [browser, ...extraInstalledSoftware]; if (browser === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); @@ -69,8 +71,8 @@ test(`playwright should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js playwright chromium firefox webkit'); await exec('node esm-playwright.mjs'); @@ -81,8 +83,8 @@ test(`playwright should work with chromium --no-shell`, async ({ exec, checkInst expect(result1).toHaveLoggedSoftwareDownload([]); await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install chromium --no-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg']); - await checkInstalledSoftwareOnDisk(['chromium', 'ffmpeg']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'ffmpeg', ...extraInstalledSoftware]); }); test(`playwright should work with chromium --only-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { @@ -90,8 +92,8 @@ test(`playwright should work with chromium --only-shell`, async ({ exec, checkIn expect(result1).toHaveLoggedSoftwareDownload([]); await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install --only-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - await checkInstalledSoftwareOnDisk(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { @@ -102,8 +104,8 @@ test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk await exec('npx playwright test -c . sample.spec.js', { expectToExitWithError: true, message: 'should not be able to run tests without installing browsers' }); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js @playwright/test chromium firefox webkit'); await exec('node', 'esm-playwright-test.mjs'); diff --git a/tests/installation/playwright-test-plugin.spec.ts b/tests/installation/playwright-test-plugin.spec.ts index 5762b49900..fe4d24cd93 100755 --- a/tests/installation/playwright-test-plugin.spec.ts +++ b/tests/installation/playwright-test-plugin.spec.ts @@ -26,7 +26,6 @@ function patchPackageJsonForPreReleaseIfNeeded(tmpWorkspace: string) { // Workaround per https://stackoverflow.com/questions/71479750/npm-install-pre-release-versions-for-peer-dependency. const pkg = JSON.parse(fs.readFileSync(path.resolve(tmpWorkspace, 'package.json'), 'utf-8')); if (pkg.dependencies['@playwright/test'].match(/\d+\.\d+-\w+/)) { - console.log(`Setting overrides in package.json to make pre-release version of peer dependency work.`); pkg.overrides = { '@playwright/test': '$@playwright/test' }; fs.writeFileSync(path.resolve(tmpWorkspace, 'package.json'), JSON.stringify(pkg, null, 2)); } From 04e670c909969869cb45ed3061dc17f726501b47 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 19 Dec 2024 15:34:54 -0800 Subject: [PATCH 42/73] fix(locator): do not explode locators (#34104) --- .../src/utils/isomorphic/locatorGenerators.ts | 4 ++-- tests/library/locator-generator.spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 04f3040547..355d0cec5f 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -36,7 +36,7 @@ export interface LocatorFactory { } export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { - return asLocators(lang, selector, isFrameLocator)[0]; + return asLocators(lang, selector, isFrameLocator, 1)[0]; } export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { @@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz const visit = (index: number) => { if (index === tokens.length) { result.push(factory.chainLocators(currentTokens)); - return currentTokens.length < maxOutputSize; + return result.length < maxOutputSize; } for (const taken of tokens[index]) { currentTokens[index] = taken; diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index cceff133d4..417e096e29 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -615,3 +615,27 @@ it('parseLocator frames', async () => { expect.soft(parseLocator('java', `locator("iframe").contentFrame().getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('java', `frameLocator("iframe").getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); }); + +it('should not oom in locator parser', async ({ page }) => { + const l = page.locator.bind(page); + const locator = page.locator('text=L1').or(l('text=L2').or(l('text=L3').or(l('text=L4')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L5').or(l('text=L6'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L7') + .or(l('text=L8'))))).or(l('text=L9').or(l('text=L10').or(l('text=L11')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L12').or(l('text=L13'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L14').or(l('text=L15'))))).or(l('text=L16').or(l('text=L17').or(l('text=L18')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L19').or(l('text=L20'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L21').or(l('text=L22'))))).or(l('text=L23').or(l('text=L24').or(l('text=L25')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L26').or(l('text=L27'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L28').or(l('text=L29'))))); + const error = await locator.count().catch(e => e); + expect(error.message).toContain('Frame locators are not allowed inside composite locators'); +}); From a94952b87fc6164601133f1020c94b6f2cecc694 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 19 Dec 2024 22:59:05 -0800 Subject: [PATCH 43/73] chore: make ts happy with zip import (#34108) --- packages/trace-viewer/src/sw/traceModelBackends.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index 95efffd502..ee694b2fba 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type zip from '@zip.js/zip.js'; +import type * as zip from '@zip.js/zip.js'; // @ts-ignore import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import type { TraceModelBackend } from './traceModel'; From cc98166aaa6341bde2293cad6420abbbd43b974b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 19 Dec 2024 22:59:24 -0800 Subject: [PATCH 44/73] chore(bidi): add csv report (#34107) --- .github/workflows/tests_bidi.yml | 7 +++ tests/bidi/csvReporter.ts | 82 ++++++++++++++++++++++++++++++++ tests/bidi/playwright.config.ts | 1 + 3 files changed, 90 insertions(+) create mode 100644 tests/bidi/csvReporter.ts diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 4fee45e34c..db54550a4c 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -44,3 +44,10 @@ jobs: run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* env: PWTEST_USE_BIDI_EXPECTATIONS: '1' + - name: Upload csv report to GitHub + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: csv-report + path: test-results/report.csv + retention-days: 7 diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts new file mode 100644 index 0000000000..8fb936dd11 --- /dev/null +++ b/tests/bidi/csvReporter.ts @@ -0,0 +1,82 @@ +/** + * 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 { + FullConfig, FullResult, Reporter, Suite +} from '@playwright/test/reporter'; +import { stripAnsi } from '../config/utils'; +import fs from 'fs'; +import path from 'path'; + + +type ReporterOptions = { + outputFile?: string, + configDir: string, +}; + +class CsvReporter implements Reporter { + private _suite: Suite; + private _options: ReporterOptions; + private _pendingWrite: Promise; + + constructor(options: ReporterOptions) { + this._options = options; + } + + onBegin(config: FullConfig, suite: Suite) { + this._suite = suite; + } + + onEnd(result: FullResult) { + const rows = [['File Name', 'Test Name', 'Expected Status', 'Status', 'Error Message']]; + for (const project of this._suite.suites) { + for (const file of project.suites) { + for (const test of file.allTests()) { + if (test.ok()) + continue; + const row = []; + row.push(file.title); + row.push(csvEscape(test.title)); + row.push(test.expectedStatus); + row.push(test.outcome()); + const result = test.results.find(r => r.error); + const errorMessage = stripAnsi(result?.error?.message.replace(/\s+/g, ' ').trim().substring(0, 1024)); + row.push(csvEscape(errorMessage ?? '')); + rows.push(row); + } + } + } + const csv = rows.map(r => r.join(',')).join('\n'); + const reportFile = path.resolve(this._options.configDir, this._options.outputFile || 'test-results.csv'); + this._pendingWrite = fs.promises.writeFile(reportFile, csv); + } + + async onExit() { + await this._pendingWrite; + } + + printsToStdio(): boolean { + return false; + } +} + +function csvEscape(str) { + if (str.includes('"') || str.includes(',') || str.includes('\n')) + return `"${str.replace(/"/g, '""')}"`; + return str; +} + +export default CsvReporter; \ No newline at end of file diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 481fa42434..8dbe5b2b9d 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -39,6 +39,7 @@ const reporters = () => { hasDebugOutput ? ['list'] : ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }], + ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ] : [ ['html', { open: 'on-failure' }], ['./expectationReporter', { rebase: false }], From 05472f5ef69a440d04ede3e85c94b1a9bc6a5682 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 20 Dec 2024 05:01:16 -0800 Subject: [PATCH 45/73] feat: Add time information to Call and Network tabs in Trace Viewer (#33935) --- packages/trace-viewer/src/ui/callTab.tsx | 67 +++++++++++-------- packages/trace-viewer/src/ui/metadataView.tsx | 4 +- .../src/ui/networkResourceDetails.tsx | 14 ++-- packages/trace-viewer/src/ui/networkTab.tsx | 2 +- packages/trace-viewer/src/ui/workbench.tsx | 2 +- tests/library/trace-viewer.spec.ts | 6 +- 6 files changed, 58 insertions(+), 37 deletions(-) diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 1ab3b5b46f..1c78920f86 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -27,50 +27,63 @@ import type { ActionTraceEventInContext } from './modelUtil'; export const CallTab: React.FunctionComponent<{ action: ActionTraceEventInContext | undefined, + startTimeOffset: number, sdkLanguage: Language | undefined, -}> = ({ action, sdkLanguage }) => { +}> = ({ action, startTimeOffset, sdkLanguage }) => { + // We never need the waitForEventInfo (`info`). + const paramKeys = React.useMemo(() => Object.keys(action?.params ?? {}).filter(name => name !== 'info'), [action]); + if (!action) return ; - const params = { ...action.params }; - // Strip down the waitForEventInfo data, we never need it. - delete params.info; - const paramKeys = Object.keys(params); - const timeMillis = action.startTime + (action.context.wallTime - action.context.startTime); - const wallTime = new Date(timeMillis).toLocaleString(); + + // Calculate execution time relative to the test runner's start time + const startTimeMillis = action.startTime - startTimeOffset; + const startTime = msToString(startTimeMillis); + const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; - return

-
{action.apiName}
- {<> -
Time
- {wallTime &&
wall time:{wallTime}
} -
duration:{duration}
- } - { !!paramKeys.length &&
Parameters
} - { - !!paramKeys.length && paramKeys.map((name, index) => renderProperty(propertyToString(action, name, params[name], sdkLanguage), 'param-' + index)) - } - { !!action.result &&
Return value
} - { - !!action.result && Object.keys(action.result).map((name, index) => - renderProperty(propertyToString(action, name, action.result[name], sdkLanguage), 'result-' + index) - ) - } -
; + return ( +
+
{action.apiName}
+ { + <> +
Time
+ + + + } + { + !!paramKeys.length && <> +
Parameters
+ {paramKeys.map(name => renderProperty(propertyToString(action, name, action.params[name], sdkLanguage)))} + + } + { + !!action.result && <> +
Return value
+ {Object.keys(action.result).map(name => + renderProperty(propertyToString(action, name, action.result[name], sdkLanguage)) + )} + + } +
+ ); }; +const DateTimeCallLine: React.FC<{ name: string, value: string }> = ({ name, value }) =>
{name}{value}
; + type Property = { name: string; type: 'string' | 'number' | 'object' | 'locator' | 'handle' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'function'; text: string; }; -function renderProperty(property: Property, key: string) { +function renderProperty(property: Property) { let text = property.text.replace(/\n/g, '↵'); if (property.type === 'string') text = `"${text}"`; return ( -
+
{property.name}:{text} { ['string', 'number', 'object', 'locator'].includes(property.type) && diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index c1802a4b4d..88c2e2bf93 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -25,9 +25,11 @@ export const MetadataView: React.FunctionComponent<{ if (!model) return <>; + const wallTime = model.wallTime !== undefined ? new Date(model.wallTime).toLocaleString(undefined, { timeZoneName: 'short' }) : undefined; + return
Time
- {!!model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
} + {!!wallTime &&
start time:{wallTime}
}
duration:{msToString(model.endTime - model.startTime)}
Browser
engine:{model.browserName}
diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 0c4af4e969..aaa78d1786 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -24,12 +24,14 @@ import { generateCurlCommand, generateFetchCall } from '../third_party/devtools' import { CopyToClipboardTextButton } from './copyToClipboard'; import { getAPIRequestCodeGen } from './codegen'; import type { Language } from '@isomorphic/locatorGenerators'; +import { msToString } from '@web/uiUtils'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot; - onClose: () => void; sdkLanguage: Language; -}> = ({ resource, onClose, sdkLanguage }) => { + startTimeOffset: number; + onClose: () => void; +}> = ({ resource, sdkLanguage, startTimeOffset, onClose }) => { const [selectedTab, setSelectedTab] = React.useState('request'); return , + render: () => , }, { id: 'response', @@ -59,7 +61,8 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{ resource: ResourceSnapshot; sdkLanguage: Language; -}> = ({ resource, sdkLanguage }) => { + startTimeOffset: number; +}> = ({ resource, sdkLanguage, startTimeOffset }) => { const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null); React.useEffect(() => { @@ -96,6 +99,9 @@ const RequestTab: React.FunctionComponent<{ : null}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+
Time
+
{`Start: ${msToString(startTimeOffset)}`}
+
{`Duration: ${msToString(resource.time)}`}
generateCurlCommand(resource)} /> diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 56cf9325b4..ceaafdcec5 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -117,7 +117,7 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedEntry(undefined)} sdkLanguage={sdkLanguage} />} + main={ setSelectedEntry(undefined)} />} sidebar={grid} />} ; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index ad8a099ea4..7a14d495c6 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -176,7 +176,7 @@ export const Workbench: React.FunctionComponent<{ const callTab: TabbedPaneTabModel = { id: 'call', title: 'Call', - render: () => + render: () => }; const logTab: TabbedPaneTabModel = { id: 'log', diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 3ffba28582..53c6bcf6c4 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -240,7 +240,7 @@ test('should show params and return value', async ({ showTraceViewer }) => { await traceViewer.selectAction('page.evaluate'); await expect(traceViewer.callLines).toHaveText([ /page.evaluate/, - /wall time:[0-9/:,APM ]+/, + /start:[\d\.]+m?s/, /duration:[\d]+ms/, /expression:"\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/, 'isFunction:true', @@ -251,7 +251,7 @@ test('should show params and return value', async ({ showTraceViewer }) => { await traceViewer.selectAction(`locator('button')`); await expect(traceViewer.callLines).toContainText([ /expect.toHaveText/, - /wall time:[0-9/:,APM ]+/, + /start:[\d\.]+m?s/, /duration:[\d]+ms/, /locator:locator\('button'\)/, /expression:"to.have.text"/, @@ -266,7 +266,7 @@ test('should show null as a param', async ({ showTraceViewer, browserName }) => await traceViewer.selectAction('page.evaluate', 1); await expect(traceViewer.callLines).toHaveText([ /page.evaluate/, - /wall time:[0-9/:,APM ]+/, + /start:[\d\.]+m?s/, /duration:[\d]+ms/, 'expression:"() => 1 + 1"', 'isFunction:true', From a8dfdc8ac5b81db585888d0728b63c387b37cd33 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 20 Dec 2024 08:43:12 -0800 Subject: [PATCH 46/73] chore(ui): Dialog UI for upcoming settings menu (#34058) --- .../trace-viewer/src/ui/shared/dialog.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 packages/trace-viewer/src/ui/shared/dialog.tsx diff --git a/packages/trace-viewer/src/ui/shared/dialog.tsx b/packages/trace-viewer/src/ui/shared/dialog.tsx new file mode 100644 index 0000000000..a58119eca7 --- /dev/null +++ b/packages/trace-viewer/src/ui/shared/dialog.tsx @@ -0,0 +1,145 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; + +export interface DialogProps { + className?: string; + open: boolean; + width: number; + verticalOffset?: number; + requestClose?: () => void; + anchor?: React.RefObject; +} + +export const Dialog: React.FC> = ({ + className, + open, + width, + verticalOffset, + requestClose, + anchor, + children, +}) => { + const dialogRef = React.useRef(null); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setRecalculateDimensionsCount] = React.useState(0); + + let style: React.CSSProperties | undefined = undefined; + + if (anchor?.current) { + const bounds = anchor.current.getBoundingClientRect(); + + style = { + margin: 0, + top: bounds.bottom + (verticalOffset ?? 0), + left: buildTopLeftCoord(bounds, width), + width, + zIndex: 1, + }; + } + + React.useEffect(() => { + const onClick = (event: MouseEvent) => { + if (!dialogRef.current || !(event.target instanceof Node)) + return; + + if (!dialogRef.current.contains(event.target)) + requestClose?.(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + requestClose?.(); + }; + + if (open) { + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKeyDown); + }; + } + + return () => {}; + }, [open, requestClose]); + + React.useEffect(() => { + const onResize = () => setRecalculateDimensionsCount(count => count + 1); + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + + return ( + open && ( + + {children} + + ) + ); +}; + +const buildTopLeftCoord = (bounds: DOMRect, width: number): number => { + const leftAlignCoord = buildTopLeftCoordWithAlignment(bounds, width, 'left'); + + if (leftAlignCoord.inBounds) + return leftAlignCoord.value; + + const rightAlignCoord = buildTopLeftCoordWithAlignment( + bounds, + width, + 'right' + ); + + if (rightAlignCoord.inBounds) + return rightAlignCoord.value; + + return leftAlignCoord.value; +}; + +const buildTopLeftCoordWithAlignment = ( + bounds: DOMRect, + width: number, + alignment: 'left' | 'right' +): { + value: number; + inBounds: boolean; +} => { + const maxLeft = document.documentElement.clientWidth; + + if (alignment === 'left') { + const value = bounds.left; + + return { + value, + inBounds: value + width <= maxLeft, + }; + } else { + const value = bounds.right - width; + + return { + value, + inBounds: bounds.right - width >= 0, + }; + } +}; From 3bc72eb84136839ff15895027cc54873fc70426d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 20 Dec 2024 08:58:15 -0800 Subject: [PATCH 47/73] chore(bidi): disambiguate report.csv artifact name (#34110) --- .github/workflows/tests_bidi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index db54550a4c..b559a87563 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -48,6 +48,6 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: csv-report + name: csv-report-${{ matrix.channel }} path: test-results/report.csv retention-days: 7 From 875436855ea247f636d5d24e17d51bd96e51f24b Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Fri, 20 Dec 2024 09:17:09 -0800 Subject: [PATCH 48/73] chore(lint): Ensure EOL newlines (#34117) --- .eslintignore | 3 +++ .eslintrc.js | 3 ++- packages/html-reporter/bundle.ts | 2 +- packages/html-reporter/src/utils.ts | 1 - packages/playwright-core/src/common/types.ts | 2 +- packages/playwright-core/src/image_tools/stats.ts | 1 - packages/playwright-core/src/server/android/android.ts | 2 -- packages/playwright-core/src/server/fileUploadUtils.ts | 2 +- packages/playwright-core/src/server/firefox/ffBrowser.ts | 1 - packages/playwright-core/src/server/firefox/firefox.ts | 1 - packages/playwright-core/src/server/registry/nativeDeps.ts | 1 - .../src/server/socksClientCertificatesInterceptor.ts | 2 +- packages/playwright-core/src/server/socksInterceptor.ts | 1 - .../playwright-core/src/server/webkit/wkProvisionalPage.ts | 2 +- packages/playwright-core/src/utils/isomorphic/mimeType.ts | 2 +- packages/playwright-core/src/utils/linuxUtils.ts | 1 - packages/playwright-core/src/utils/sequence.ts | 2 +- packages/playwright/jsx-runtime.mjs | 2 +- packages/playwright/src/common/config.ts | 2 +- packages/playwright/src/runner/vcs.ts | 2 +- packages/trace-viewer/bundle.ts | 2 +- packages/trace-viewer/src/sw/traceModelBackends.ts | 2 +- packages/trace-viewer/src/third_party/devtools.ts | 2 +- packages/trace-viewer/src/ui/actionList.tsx | 2 +- packages/web/src/components/sourceChooser.tsx | 2 +- packages/web/src/components/splitView.spec.tsx | 1 - tests/android/webview.spec.ts | 2 +- tests/bidi/csvReporter.ts | 2 +- tests/bidi/expectationReporter.ts | 2 +- tests/expect/toThrowMatchers.test.ts | 2 +- tests/library/browsercontext-har.spec.ts | 2 +- tests/library/chromium/chromium.spec.ts | 2 +- tests/library/inspector/cli-codegen-test.spec.ts | 2 +- tests/library/popup.spec.ts | 2 +- tests/page/frame-evaluate.spec.ts | 1 - tests/page/locator-misc-2.spec.ts | 1 - tests/page/page-event-request.spec.ts | 2 +- tests/page/page-mouse.spec.ts | 1 - tests/page/page-request-fulfill.spec.ts | 1 - tests/page/selectors-react.spec.ts | 1 - tests/page/selectors-vue.spec.ts | 1 - tests/playwright-test/command-line-filter.spec.ts | 2 +- tests/playwright-test/only-changed.spec.ts | 1 - tests/playwright-test/playwright.reuse.browser.spec.ts | 2 +- tests/playwright-test/reporter-blob.spec.ts | 2 +- tests/playwright-test/reporter-dot.spec.ts | 2 +- tests/playwright-test/reporter-github.spec.ts | 2 +- tests/playwright-test/reporter-junit.spec.ts | 2 +- tests/playwright-test/reporter-line.spec.ts | 2 +- tests/playwright-test/reporter-list.spec.ts | 1 - tests/playwright-test/reporter-markdown.spec.ts | 2 +- tests/playwright-test/snapshot-path-template.spec.ts | 1 - tests/playwright-test/test-ignore.spec.ts | 2 +- tests/playwright-test/test-use.spec.ts | 1 - tests/playwright-test/ui-mode-test-annotations.spec.ts | 1 - utils/doclint/linting-code-snippets/cli.js | 1 + 56 files changed, 39 insertions(+), 55 deletions(-) diff --git a/.eslintignore b/.eslintignore index 9d22f618d8..fdcc76dda0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,9 @@ test/assets/modernizr.js /packages/*/lib/ *.js /packages/playwright-core/src/generated/* +/packages/playwright-core/src/protocol/debug.ts +/packages/playwright-core/src/protocol/validator.ts +/packages/playwright-core/src/server/injected/recorder/clipPaths.ts /packages/playwright-core/src/third_party/ /packages/playwright-core/types/* /packages/playwright-ct-core/src/generated/* diff --git a/.eslintrc.js b/.eslintrc.js index a116a37036..e71a4ffd09 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -115,7 +115,7 @@ module.exports = { "@typescript-eslint/type-annotation-spacing": 2, // file whitespace - "no-multiple-empty-lines": [2, {"max": 2}], + "no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 0}], "no-mixed-spaces-and-tabs": 2, "no-trailing-spaces": 2, "linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ], @@ -123,6 +123,7 @@ module.exports = { "key-spacing": [2, { "beforeColon": false }], + "eol-last": 2, // copyright "notice/notice": [2, { diff --git a/packages/html-reporter/bundle.ts b/packages/html-reporter/bundle.ts index 4c6bc02632..63530cfaad 100644 --- a/packages/html-reporter/bundle.ts +++ b/packages/html-reporter/bundle.ts @@ -51,4 +51,4 @@ export function bundle(): Plugin { } }, }; -} \ No newline at end of file +} diff --git a/packages/html-reporter/src/utils.ts b/packages/html-reporter/src/utils.ts index 65404b2fe7..eec765969b 100644 --- a/packages/html-reporter/src/utils.ts +++ b/packages/html-reporter/src/utils.ts @@ -47,4 +47,3 @@ export function hashStringToInt(str: string) { hash = str.charCodeAt(i) + ((hash << 8) - hash); return Math.abs(hash % 6); } - diff --git a/packages/playwright-core/src/common/types.ts b/packages/playwright-core/src/common/types.ts index 55145b5565..f71d4237cd 100644 --- a/packages/playwright-core/src/common/types.ts +++ b/packages/playwright-core/src/common/types.ts @@ -20,4 +20,4 @@ export type Rect = Size & Point; export type Quad = [ Point, Point, Point, Point ]; export type TimeoutOptions = { timeout?: number }; export type NameValue = { name: string, value: string }; -export type HeadersArray = NameValue[]; \ No newline at end of file +export type HeadersArray = NameValue[]; diff --git a/packages/playwright-core/src/image_tools/stats.ts b/packages/playwright-core/src/image_tools/stats.ts index b35371b4a1..79b170243c 100644 --- a/packages/playwright-core/src/image_tools/stats.ts +++ b/packages/playwright-core/src/image_tools/stats.ts @@ -124,4 +124,3 @@ export class FastStats implements Stats { return (this._sum(this._partialSumMult, x1, y1, x2, y2) - this._sum(this._partialSumC1, x1, y1, x2, y2) * this._sum(this._partialSumC2, x1, y1, x2, y2) / N) / N; } } - diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index 1af083916c..b6303935b7 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -524,5 +524,3 @@ class ClankBrowserProcess implements BrowserProcess { await this._browser.close(); } } - - diff --git a/packages/playwright-core/src/server/fileUploadUtils.ts b/packages/playwright-core/src/server/fileUploadUtils.ts index 22ac13b127..2696ba0116 100644 --- a/packages/playwright-core/src/server/fileUploadUtils.ts +++ b/packages/playwright-core/src/server/fileUploadUtils.ts @@ -77,4 +77,4 @@ export async function prepareFilesForUpload(frame: Frame, params: channels.Eleme })); return { localPaths, localDirectory, filePayloads }; -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 92998a7946..1d1c60e6ce 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -436,4 +436,3 @@ function toJugglerProxyOptions(proxy: types.ProxySettings) { // Prefs for quick fixes that didn't make it to the build. // Should all be moved to `playwright.cfg`. const kBandaidFirefoxUserPrefs = {}; - diff --git a/packages/playwright-core/src/server/firefox/firefox.ts b/packages/playwright-core/src/server/firefox/firefox.ts index 9fbc409a56..1e0e4cc055 100644 --- a/packages/playwright-core/src/server/firefox/firefox.ts +++ b/packages/playwright-core/src/server/firefox/firefox.ts @@ -101,4 +101,3 @@ class JugglerReadyState extends BrowserReadyState { this._wsEndpoint.resolve(undefined); } } - diff --git a/packages/playwright-core/src/server/registry/nativeDeps.ts b/packages/playwright-core/src/server/registry/nativeDeps.ts index 6e25e3a14f..9653210a45 100644 --- a/packages/playwright-core/src/server/registry/nativeDeps.ts +++ b/packages/playwright-core/src/server/registry/nativeDeps.ts @@ -1104,4 +1104,3 @@ deps['debian12-arm64'] = { ...deps['debian12-x64'].lib2package, }, }; - diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 4e850f4a84..2517ea24ce 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -354,4 +354,4 @@ export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', ].join('\n')); -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/server/socksInterceptor.ts b/packages/playwright-core/src/server/socksInterceptor.ts index 498e8bfe73..6b29636a00 100644 --- a/packages/playwright-core/src/server/socksInterceptor.ts +++ b/packages/playwright-core/src/server/socksInterceptor.ts @@ -83,4 +83,3 @@ export class SocksInterceptor { function tChannelForSocks(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { throw new ValidationError(`${path}: channels are not expected in SocksSupport`); } - diff --git a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts index b8af1b9ca3..6d7459c978 100644 --- a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts +++ b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts @@ -105,4 +105,4 @@ export class WKProvisionalPage { assert(!frameTree.frame.parentId); this._mainFrameId = frameTree.frame.id; } -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/utils/isomorphic/mimeType.ts b/packages/playwright-core/src/utils/isomorphic/mimeType.ts index 2f8b9d4829..407d935281 100644 --- a/packages/playwright-core/src/utils/isomorphic/mimeType.ts +++ b/packages/playwright-core/src/utils/isomorphic/mimeType.ts @@ -20,4 +20,4 @@ export function isJsonMimeType(mimeType: string) { export function isTextualMimeType(mimeType: string) { return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/); -} \ No newline at end of file +} diff --git a/packages/playwright-core/src/utils/linuxUtils.ts b/packages/playwright-core/src/utils/linuxUtils.ts index d8e227f107..5d98c823a5 100644 --- a/packages/playwright-core/src/utils/linuxUtils.ts +++ b/packages/playwright-core/src/utils/linuxUtils.ts @@ -77,4 +77,3 @@ function parseOSReleaseText(osReleaseText: string): Map { } return fields; } - diff --git a/packages/playwright-core/src/utils/sequence.ts b/packages/playwright-core/src/utils/sequence.ts index 27756fabeb..b063e5c488 100644 --- a/packages/playwright-core/src/utils/sequence.ts +++ b/packages/playwright-core/src/utils/sequence.ts @@ -63,4 +63,4 @@ export function findRepeatedSubsequences(s: string[]): { sequence: string[]; cou } return result; -} \ No newline at end of file +} diff --git a/packages/playwright/jsx-runtime.mjs b/packages/playwright/jsx-runtime.mjs index 742708825e..1dfd5835aa 100644 --- a/packages/playwright/jsx-runtime.mjs +++ b/packages/playwright/jsx-runtime.mjs @@ -18,4 +18,4 @@ import jsxRuntime from './jsx-runtime.js'; export const jsx = jsxRuntime.jsx; export const jsxs = jsxRuntime.jsxs; -export const Fragment = jsxRuntime.Fragment; \ No newline at end of file +export const Fragment = jsxRuntime.Fragment; diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 0e8babce10..a86fbb5157 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -298,4 +298,4 @@ const configInternalSymbol = Symbol('configInternalSymbol'); export function getProjectId(project: FullProject): string { return (project as any).__projectId!; -} \ No newline at end of file +} diff --git a/packages/playwright/src/runner/vcs.ts b/packages/playwright/src/runner/vcs.ts index 6f7ed55c9a..da7c4c2cc8 100644 --- a/packages/playwright/src/runner/vcs.ts +++ b/packages/playwright/src/runner/vcs.ts @@ -54,4 +54,4 @@ export async function detectChangedTestFiles(baseCommit: string, configDir: stri const trackedFilesWithChanges = gitFileList(`diff ${baseCommit} --name-only`).map(file => path.join(gitRoot, file)); return new Set(affectedTestFiles([...untrackedFiles, ...trackedFilesWithChanges])); -} \ No newline at end of file +} diff --git a/packages/trace-viewer/bundle.ts b/packages/trace-viewer/bundle.ts index eaf09a6cc3..38375f8d76 100644 --- a/packages/trace-viewer/bundle.ts +++ b/packages/trace-viewer/bundle.ts @@ -28,4 +28,4 @@ export function bundle(): Plugin { }, }, }; -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index ee694b2fba..4f8ea94c3d 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -160,4 +160,4 @@ export class TraceViewerServer { return; return response; } -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/third_party/devtools.ts b/packages/trace-viewer/src/third_party/devtools.ts index 27c520cbce..cc03330240 100644 --- a/packages/trace-viewer/src/third_party/devtools.ts +++ b/packages/trace-viewer/src/third_party/devtools.ts @@ -282,4 +282,4 @@ export async function generateFetchCall(resource: Entry, style: FetchStyle = Fet async function fetchRequestPostData(resource: Entry) { return resource.request.postData?._sha1 ? await fetch(`sha1/${resource.request.postData._sha1}`).then(r => r.text()) : resource.request.postData?.text; -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 101c532aea..1deb8ecd88 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -150,4 +150,4 @@ function excludeOrigin(url: string): string { } catch (error) { return url; } -} \ No newline at end of file +} diff --git a/packages/web/src/components/sourceChooser.tsx b/packages/web/src/components/sourceChooser.tsx index 22b91c61a5..091dce8f98 100644 --- a/packages/web/src/components/sourceChooser.tsx +++ b/packages/web/src/components/sourceChooser.tsx @@ -59,4 +59,4 @@ export function emptySource(): Source { label: '', highlight: [] }; -} \ No newline at end of file +} diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index a9260a2d48..ca11520912 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -90,4 +90,3 @@ test('drag resize', async ({ page, mount }) => { expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 100 }); expect.soft(sidebarBox).toEqual({ x: 0, y: 101, width: 500, height: 399 }); }); - diff --git a/tests/android/webview.spec.ts b/tests/android/webview.spec.ts index a0d03b69b6..fac61b5a9a 100644 --- a/tests/android/webview.spec.ts +++ b/tests/android/webview.spec.ts @@ -68,4 +68,4 @@ test('select webview from socketName', async function({ androidDevice }) { await newPage.close(); await context.close(); -}); \ No newline at end of file +}); diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts index 8fb936dd11..4986a736c3 100644 --- a/tests/bidi/csvReporter.ts +++ b/tests/bidi/csvReporter.ts @@ -79,4 +79,4 @@ function csvEscape(str) { return str; } -export default CsvReporter; \ No newline at end of file +export default CsvReporter; diff --git a/tests/bidi/expectationReporter.ts b/tests/bidi/expectationReporter.ts index e136149cc4..65371165ff 100644 --- a/tests/bidi/expectationReporter.ts +++ b/tests/bidi/expectationReporter.ts @@ -86,4 +86,4 @@ function getOutcome(test: TestCase): TestExpectation { return 'unknown'; } -export default ExpectationReporter; \ No newline at end of file +export default ExpectationReporter; diff --git a/tests/expect/toThrowMatchers.test.ts b/tests/expect/toThrowMatchers.test.ts index a5d5e52ba8..bc64d13240 100644 --- a/tests/expect/toThrowMatchers.test.ts +++ b/tests/expect/toThrowMatchers.test.ts @@ -588,4 +588,4 @@ for (const toThrow of ['toThrowError', 'toThrow'] as const) { ).toThrowErrorMatchingSnapshot(); }); }); -} \ No newline at end of file +} diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 4a7834013a..796a37426a 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -556,4 +556,4 @@ it('should ignore aborted requests', async ({ contextFactory, server }) => { const result = await Promise.race([evalPromise, page2.waitForTimeout(1000).then(() => 'timeout')]); expect(result).toBe('timeout'); } -}); \ No newline at end of file +}); diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 61aa811dca..910e0830ff 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -629,4 +629,4 @@ test.describe('PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', () => { const req = await requestPromise; expect(req.headers['x-custom-header']).toBe('custom!'); }); -}); \ No newline at end of file +}); diff --git a/tests/library/inspector/cli-codegen-test.spec.ts b/tests/library/inspector/cli-codegen-test.spec.ts index ad68e75c48..b24871877f 100644 --- a/tests/library/inspector/cli-codegen-test.spec.ts +++ b/tests/library/inspector/cli-codegen-test.spec.ts @@ -122,4 +122,4 @@ test('should generate routeFromHAR with --save-har and --save-har-glob', async ( await cli.waitForCleanExit(); const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); -}); \ No newline at end of file +}); diff --git a/tests/library/popup.spec.ts b/tests/library/popup.spec.ts index 1dcede604b..8f2f1df7dc 100644 --- a/tests/library/popup.spec.ts +++ b/tests/library/popup.spec.ts @@ -288,4 +288,4 @@ async function waitForRafs(page: Page, count: number): Promise { }; window.builtinRequestAnimationFrame(onRaf); }), count); -} \ No newline at end of file +} diff --git a/tests/page/frame-evaluate.spec.ts b/tests/page/frame-evaluate.spec.ts index 16bcf6eac7..6908f03e86 100644 --- a/tests/page/frame-evaluate.spec.ts +++ b/tests/page/frame-evaluate.spec.ts @@ -180,4 +180,3 @@ it('evaluateHandle should work', async ({ page, server }) => { const windowHandle = await mainFrame.evaluateHandle(() => window); expect(windowHandle).toBeTruthy(); }); - diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index b32913faba..202b9a2957 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -173,4 +173,3 @@ it('Locator.locator() and FrameLocator.locator() should accept locator', async ( expect(await divLocator.locator('input').inputValue()).toBe('outer'); expect(await page.frameLocator('iframe').locator(divLocator).locator('input').inputValue()).toBe('inner'); }); - diff --git a/tests/page/page-event-request.spec.ts b/tests/page/page-event-request.spec.ts index 2c1d7a7eba..e1fc29eb59 100644 --- a/tests/page/page-event-request.spec.ts +++ b/tests/page/page-event-request.spec.ts @@ -272,4 +272,4 @@ it(' resource should have type image', async ({ page }) => { `) ]); expect(request.resourceType()).toBe('image'); -}); \ No newline at end of file +}); diff --git a/tests/page/page-mouse.spec.ts b/tests/page/page-mouse.spec.ts index f93ca4e2e2..7f4ce8a85d 100644 --- a/tests/page/page-mouse.spec.ts +++ b/tests/page/page-mouse.spec.ts @@ -309,4 +309,3 @@ it('should dispatch mouse move after context menu was opened', async ({ page, br } } }); - diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 3edcd0ba0a..0d939a0a5c 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -485,4 +485,3 @@ it('should not go to the network for fulfilled requests body', { expect(body).toBeTruthy(); expect(serverHit).toBe(false); }); - diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index ce47f1bb25..c7bfc3f68a 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -176,4 +176,3 @@ for (const [name, url] of Object.entries(reacts)) { }); }); } - diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index 175e1246ee..71b60d1cb3 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -168,4 +168,3 @@ for (const [name, url] of Object.entries(vues)) { }); }); } - diff --git a/tests/playwright-test/command-line-filter.spec.ts b/tests/playwright-test/command-line-filter.spec.ts index 8937694b39..e33cb8b318 100644 --- a/tests/playwright-test/command-line-filter.spec.ts +++ b/tests/playwright-test/command-line-filter.spec.ts @@ -195,4 +195,4 @@ test('should focus a single test suite', async ({ runInlineTest }) => { expect(result.skipped).toBe(0); expect(result.report.suites[0].suites[0].suites[0].specs[0].title).toEqual('pass2'); expect(result.report.suites[0].suites[0].suites[0].specs[1].title).toEqual('pass3'); -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/only-changed.spec.ts b/tests/playwright-test/only-changed.spec.ts index 5a9c6ee121..00614598de 100644 --- a/tests/playwright-test/only-changed.spec.ts +++ b/tests/playwright-test/only-changed.spec.ts @@ -427,4 +427,3 @@ test('exits successfully if there are no changes', async ({ runInlineTest, git, expect(result.exitCode).toBe(0); }); - diff --git a/tests/playwright-test/playwright.reuse.browser.spec.ts b/tests/playwright-test/playwright.reuse.browser.spec.ts index bccce95b3e..4340550f64 100644 --- a/tests/playwright-test/playwright.reuse.browser.spec.ts +++ b/tests/playwright-test/playwright.reuse.browser.spec.ts @@ -148,4 +148,4 @@ test('should produce correct test steps', async ({ runInlineTest, runServer }) = 'onStepEnd fixture: context', 'onStepEnd After Hooks' ]); -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index 91b32e76a2..5ddb271b70 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -2052,4 +2052,4 @@ test('project filter in report name', async ({ runInlineTest }) => { const reportFiles = await fs.promises.readdir(reportDir); expect(reportFiles.sort()).toEqual(['report-foo-b-r-6d9d49e-1.zip']); } -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/reporter-dot.spec.ts b/tests/playwright-test/reporter-dot.spec.ts index 5afda3f7bd..04a9337ec0 100644 --- a/tests/playwright-test/reporter-dot.spec.ts +++ b/tests/playwright-test/reporter-dot.spec.ts @@ -112,4 +112,4 @@ for (const useIntermediateMergeReport of [false, true] as const) { colors.green('·').repeat(3)); }); }); -} \ No newline at end of file +} diff --git a/tests/playwright-test/reporter-github.spec.ts b/tests/playwright-test/reporter-github.spec.ts index 100feb157f..292c407b9f 100644 --- a/tests/playwright-test/reporter-github.spec.ts +++ b/tests/playwright-test/reporter-github.spec.ts @@ -98,4 +98,4 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(result.exitCode).toBe(1); }); }); -} \ No newline at end of file +} diff --git a/tests/playwright-test/reporter-junit.spec.ts b/tests/playwright-test/reporter-junit.spec.ts index 2b182e00c0..9947484903 100644 --- a/tests/playwright-test/reporter-junit.spec.ts +++ b/tests/playwright-test/reporter-junit.spec.ts @@ -594,4 +594,4 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(time).toBeGreaterThan(1); }); }); -} \ No newline at end of file +} diff --git a/tests/playwright-test/reporter-line.spec.ts b/tests/playwright-test/reporter-line.spec.ts index 22441d567a..7322af433f 100644 --- a/tests/playwright-test/reporter-line.spec.ts +++ b/tests/playwright-test/reporter-line.spec.ts @@ -189,4 +189,4 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(result.exitCode).toBe(1); }); }); -} \ No newline at end of file +} diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts index 752d29c649..1d488cc253 100644 --- a/tests/playwright-test/reporter-list.spec.ts +++ b/tests/playwright-test/reporter-list.spec.ts @@ -319,4 +319,3 @@ function simpleAnsiRenderer(text, ttyWidth) { return screenLines.map(line => line.join('')).join('\n'); } - diff --git a/tests/playwright-test/reporter-markdown.spec.ts b/tests/playwright-test/reporter-markdown.spec.ts index 076e28d66e..558a1f8a84 100644 --- a/tests/playwright-test/reporter-markdown.spec.ts +++ b/tests/playwright-test/reporter-markdown.spec.ts @@ -157,4 +157,4 @@ test('report with worker error', async ({ runInlineTest }) => { **0 passed** :heavy_check_mark::heavy_check_mark::heavy_check_mark: `); -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/snapshot-path-template.spec.ts b/tests/playwright-test/snapshot-path-template.spec.ts index 4f260469b7..2fd79403f4 100644 --- a/tests/playwright-test/snapshot-path-template.spec.ts +++ b/tests/playwright-test/snapshot-path-template.spec.ts @@ -137,4 +137,3 @@ test('arg should receive default arg', async ({ runInlineTest }, testInfo) => { expect(result.output).toContain(`A snapshot doesn't exist at ${snapshotOutputPath}, writing actual`); expect(fs.existsSync(snapshotOutputPath)).toBe(true); }); - diff --git a/tests/playwright-test/test-ignore.spec.ts b/tests/playwright-test/test-ignore.spec.ts index b552380faa..17e3b3adae 100644 --- a/tests/playwright-test/test-ignore.spec.ts +++ b/tests/playwright-test/test-ignore.spec.ts @@ -370,4 +370,4 @@ test('should always work with unix separators', async ({ runInlineTest }) => { expect(result.passed).toBe(1); expect(result.report.suites.map(s => s.file).sort()).toEqual(['a.test.ts']); expect(result.exitCode).toBe(0); -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/test-use.spec.ts b/tests/playwright-test/test-use.spec.ts index 8a2bcd2401..1687cbe2e7 100644 --- a/tests/playwright-test/test-use.spec.ts +++ b/tests/playwright-test/test-use.spec.ts @@ -186,4 +186,3 @@ test('test.use() should throw if called from beforeAll ', async ({ runInlineTest expect(result.exitCode).toBe(1); expect(result.output).toContain('Playwright Test did not expect test.use() to be called here'); }); - diff --git a/tests/playwright-test/ui-mode-test-annotations.spec.ts b/tests/playwright-test/ui-mode-test-annotations.spec.ts index eeff6a5aca..cc1f0b5f04 100644 --- a/tests/playwright-test/ui-mode-test-annotations.spec.ts +++ b/tests/playwright-test/ui-mode-test-annotations.spec.ts @@ -46,4 +46,3 @@ test('should display annotations', async ({ runUITest }) => { await expect(annotations.locator('.annotation-item').filter({ hasText: 'test repo' }).locator('a')) .toHaveAttribute('href', 'https://github.com/microsoft/playwright'); }); - diff --git a/utils/doclint/linting-code-snippets/cli.js b/utils/doclint/linting-code-snippets/cli.js index 5d3200aa9e..7139bb105a 100644 --- a/utils/doclint/linting-code-snippets/cli.js +++ b/utils/doclint/linting-code-snippets/cli.js @@ -153,6 +153,7 @@ class JSLintingService extends LintingService { '@typescript-eslint/no-unused-vars': 'off', 'max-len': ['error', { code: 100 }], 'react/react-in-jsx-scope': 'off', + 'eol-last': 'off', }, } }); From a74c488b2562ce72af417fa6036e4f9a49af4f10 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 20 Dec 2024 10:24:10 -0800 Subject: [PATCH 49/73] docs: document --no-shell option (#34120) --- docs/src/api/params.md | 2 +- docs/src/browsers.md | 30 ++++++++++++++++++++--- docs/src/chrome-extensions-js-python.md | 2 +- packages/playwright-core/types/types.d.ts | 6 ++--- packages/playwright/types/test.d.ts | 2 +- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index e059fffe46..a1f909a4fb 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1003,7 +1003,7 @@ Additional arguments to pass to the browser instance. The list of Chromium flags Browser distribution channel. -Use "chromium" to [opt in to new headless mode](../browsers.md#opt-in-to-new-headless-mode). +Use "chromium" to [opt in to new headless mode](../browsers.md#chromium-new-headless-mode). Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or "msedge-canary" to use branded [Google Chrome and Microsoft Edge](../browsers.md#google-chrome--microsoft-edge). diff --git a/docs/src/browsers.md b/docs/src/browsers.md index 90c0b2850b..1cc10d7a8d 100644 --- a/docs/src/browsers.md +++ b/docs/src/browsers.md @@ -338,11 +338,11 @@ dotnet test --settings:webkit.runsettings For Google Chrome, Microsoft Edge and other Chromium-based browsers, by default, Playwright uses open source Chromium builds. Since the Chromium project is ahead of the branded browsers, when the world is on Google Chrome N, Playwright already supports Chromium N+1 that will be released in Google Chrome and Microsoft Edge a few weeks later. -Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode. See [issue #33566](https://github.com/microsoft/playwright/issues/33566) for details. +### Chromium: headless shell -#### Optimize download size on CI +Playwright ships a regular Chromium build for headed operations and a separate [chromium headless shell](https://developer.chrome.com/blog/chrome-headless-shell) for headless mode. -If you are only running tests in headless shell (i.e. the `channel` option is not specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation. +If you are only running tests in headless shell (i.e. the `channel` option is **not** specified), for example on CI, you can avoid downloading the full Chromium browser by passing `--only-shell` during installation. ```bash js # only running tests headlessly @@ -364,7 +364,7 @@ playwright install --with-deps --only-shell pwsh bin/Debug/netX/playwright.ps1 install --with-deps --only-shell ``` -#### Opt-in to new headless mode +### Chromium: new headless mode You can opt into the new headless mode by using `'chromium'` channel. As [official Chrome documentation puts it](https://developer.chrome.com/blog/chrome-headless-shell): @@ -419,6 +419,28 @@ pytest test_login.py --browser-channel chromium dotnet test -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Channel=chromium ``` +With the new headless mode, you can skip downloading the headless shell during browser installation by using the `--no-shell` option: + +```bash js +# only running tests headlessly +npx playwright install --with-deps --no-shell +``` + +```bash java +# only running tests headlessly +mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps --no-shell" +``` + +```bash python +# only running tests headlessly +playwright install --with-deps --no-shell +``` + +```bash csharp +# only running tests headlessly +pwsh bin/Debug/netX/playwright.ps1 install --with-deps --no-shell +``` + ### Google Chrome & Microsoft Edge While Playwright can download and use the recent Chromium build, it can operate against the branded Google Chrome and Microsoft Edge browsers available on the machine (note that Playwright doesn't install them by default). In particular, the current Playwright version will support Stable and Beta channels of these browsers. diff --git a/docs/src/chrome-extensions-js-python.md b/docs/src/chrome-extensions-js-python.md index 1142e9b3a7..edbe7c06c9 100644 --- a/docs/src/chrome-extensions-js-python.md +++ b/docs/src/chrome-extensions-js-python.md @@ -214,7 +214,7 @@ def test_popup_page(page: Page, extension_id: str) -> None: ## Headless mode -By default, Chrome's headless mode in Playwright does not support Chrome extensions. To overcome this limitation, you can run Chrome's persistent context with a new headless mode by using [channel `chromium`](./browsers.md#opt-in-to-new-headless-mode): +By default, Chrome's headless mode in Playwright does not support Chrome extensions. To overcome this limitation, you can run Chrome's persistent context with a new headless mode by using [channel `chromium`](./browsers.md#chromium-new-headless-mode): ```js title="fixtures.ts" // ... diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index ba83204e7e..460a0c7edf 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14716,7 +14716,7 @@ export interface BrowserType { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). @@ -15215,7 +15215,7 @@ export interface BrowserType { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). @@ -21566,7 +21566,7 @@ export interface LaunchOptions { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index caed95b8d5..85e3d37847 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6002,7 +6002,7 @@ export interface PlaywrightWorkerOptions { /** * Browser distribution channel. * - * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode). + * Use "chromium" to [opt in to new headless mode](https://playwright.dev/docs/browsers#chromium-new-headless-mode). * * Use "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", or * "msedge-canary" to use branded [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). From c89e213eff6d03ba79202d369a393e6124e42fa6 Mon Sep 17 00:00:00 2001 From: Evan Cahill Date: Fri, 20 Dec 2024 13:23:01 -0800 Subject: [PATCH 50/73] docs: Use locator.first() in locator.or examples (#34106) --- docs/src/api/class-locator.md | 17 +++++++++++------ packages/playwright-core/types/types.d.ts | 10 +++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index e93d02d9d8..38a3546e41 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1717,16 +1717,21 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2); Creates a locator matching all elements that match one or both of the two locators. -Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines. +Note that when both locators match something, the resulting locator will have multiple matches, potentially causing a [locator strictness](../locators.md#strictness) violation. **Usage** Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly. +:::note +If both "New email" button and security dialog appear on screen, the "or" locator will match both of them, +possibly throwing the ["strict mode violation" error](../locators.md#strictness). In this case, you can use [`method: Locator.first`] to only match one of them. +::: + ```js const newEmail = page.getByRole('button', { name: 'New' }); const dialog = page.getByText('Confirm security settings'); -await expect(newEmail.or(dialog)).toBeVisible(); +await expect(newEmail.or(dialog).first()).toBeVisible(); if (await dialog.isVisible()) await page.getByRole('button', { name: 'Dismiss' }).click(); await newEmail.click(); @@ -1735,7 +1740,7 @@ await newEmail.click(); ```java Locator newEmail = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("New")); Locator dialog = page.getByText("Confirm security settings"); -assertThat(newEmail.or(dialog)).isVisible(); +assertThat(newEmail.or(dialog).first()).isVisible(); if (dialog.isVisible()) page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Dismiss")).click(); newEmail.click(); @@ -1744,7 +1749,7 @@ newEmail.click(); ```python async new_email = page.get_by_role("button", name="New") dialog = page.get_by_text("Confirm security settings") -await expect(new_email.or_(dialog)).to_be_visible() +await expect(new_email.or_(dialog).first).to_be_visible() if (await dialog.is_visible()): await page.get_by_role("button", name="Dismiss").click() await new_email.click() @@ -1753,7 +1758,7 @@ await new_email.click() ```python sync new_email = page.get_by_role("button", name="New") dialog = page.get_by_text("Confirm security settings") -expect(new_email.or_(dialog)).to_be_visible() +expect(new_email.or_(dialog).first).to_be_visible() if (dialog.is_visible()): page.get_by_role("button", name="Dismiss").click() new_email.click() @@ -1762,7 +1767,7 @@ new_email.click() ```csharp var newEmail = page.GetByRole(AriaRole.Button, new() { Name = "New" }); var dialog = page.GetByText("Confirm security settings"); -await Expect(newEmail.Or(dialog)).ToBeVisibleAsync(); +await Expect(newEmail.Or(dialog).First).ToBeVisibleAsync(); if (await dialog.IsVisibleAsync()) await page.GetByRole(AriaRole.Button, new() { Name = "Dismiss" }).ClickAsync(); await newEmail.ClickAsync(); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 460a0c7edf..78c1c668c4 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13853,18 +13853,22 @@ export interface Locator { /** * Creates a locator matching all elements that match one or both of the two locators. * - * Note that when both locators match something, the resulting locator will have multiple matches and violate - * [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines. + * Note that when both locators match something, the resulting locator will have multiple matches, potentially causing + * a [locator strictness](https://playwright.dev/docs/locators#strictness) violation. * * **Usage** * * Consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog * shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly. * + * **NOTE** If both "New email" button and security dialog appear on screen, the "or" locator will match both of them, + * possibly throwing the ["strict mode violation" error](https://playwright.dev/docs/locators#strictness). In this case, you can use + * [locator.first()](https://playwright.dev/docs/api/class-locator#locator-first) to only match one of them. + * * ```js * const newEmail = page.getByRole('button', { name: 'New' }); * const dialog = page.getByText('Confirm security settings'); - * await expect(newEmail.or(dialog)).toBeVisible(); + * await expect(newEmail.or(dialog).first()).toBeVisible(); * if (await dialog.isVisible()) * await page.getByRole('button', { name: 'Dismiss' }).click(); * await newEmail.click(); From cce8e8e0e5c367bf3510eec59d2ab1c17017c7b6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 20 Dec 2024 14:03:38 -0800 Subject: [PATCH 51/73] chore(html): use api prefix to qualify public types (#34121) --- packages/playwright/src/reporters/html.ts | 34 +++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 302d532641..75d345e319 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -21,7 +21,7 @@ import path from 'path'; 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 * as api from '../../types/testReporter'; 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'; @@ -56,8 +56,8 @@ type HtmlReporterOptions = { }; class HtmlReporter implements ReporterV2 { - private config!: FullConfig; - private suite!: Suite; + private config!: api.FullConfig; + private suite!: api.Suite; private _options: HtmlReporterOptions; private _outputFolder!: string; private _attachmentsBaseURL!: string; @@ -65,7 +65,7 @@ class HtmlReporter implements ReporterV2 { private _port: number | undefined; private _host: string | undefined; private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined; - private _topLevelErrors: TestError[] = []; + private _topLevelErrors: api.TestError[] = []; constructor(options: HtmlReporterOptions) { this._options = options; @@ -79,11 +79,11 @@ class HtmlReporter implements ReporterV2 { return false; } - onConfigure(config: FullConfig) { + onConfigure(config: api.FullConfig) { this.config = config; } - onBegin(suite: Suite) { + onBegin(suite: api.Suite) { const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions(); this._outputFolder = outputFolder; this._open = open; @@ -125,11 +125,11 @@ class HtmlReporter implements ReporterV2 { return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } - onError(error: TestError): void { + onError(error: api.TestError): void { this._topLevelErrors.push(error); } - async onEnd(result: FullResult) { + async onEnd(result: api.FullResult) { const projectSuites = this.suite.suites; await removeFolders([this._outputFolder]); const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL); @@ -223,14 +223,14 @@ export function startHtmlReportServer(folder: string): HttpServer { } class HtmlBuilder { - private _config: FullConfig; + private _config: api.FullConfig; private _reportFolder: string; private _stepsInFile = new MultiMap(); private _dataZipFile: ZipFile; private _hasTraces = false; private _attachmentsBaseURL: string; - constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) { + constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string) { this._config = config; this._reportFolder = outputDir; fs.mkdirSync(this._reportFolder, { recursive: true }); @@ -238,7 +238,7 @@ class HtmlBuilder { this._attachmentsBaseURL = attachmentsBaseURL; } - async build(metadata: Metadata, projectSuites: Suite[], result: FullResult, topLevelErrors: TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { + async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); for (const projectSuite of projectSuites) { for (const fileSuite of projectSuite.suites) { @@ -378,7 +378,7 @@ class HtmlBuilder { this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName); } - private _processSuite(suite: Suite, projectName: string, path: string[], outTests: TestEntry[]) { + private _processSuite(suite: api.Suite, projectName: string, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; suite.entries().forEach(e => { if (e.type === 'test') @@ -388,7 +388,7 @@ class HtmlBuilder { }); } - private _createTestEntry(test: TestCasePublic, projectName: string, path: string[]): TestEntry { + private _createTestEntry(test: api.TestCase, 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).filter(path => path.length > 0); @@ -500,7 +500,7 @@ class HtmlBuilder { }).filter(Boolean) as TestAttachment[]; } - private _createTestResult(test: TestCasePublic, result: TestResultPublic): TestResult { + private _createTestResult(test: api.TestCase, result: api.TestResult): TestResult { return { duration: result.duration, startTime: result.startTime.toISOString(), @@ -531,7 +531,7 @@ class HtmlBuilder { return result; } - private _relativeLocation(location: Location | undefined): Location | undefined { + private _relativeLocation(location: api.Location | undefined): api.Location | undefined { if (!location) return undefined; const file = toPosixPath(path.relative(this._config.rootDir, location.file)); @@ -609,9 +609,9 @@ function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): Jso }; } -type DedupedStep = { step: TestStepPublic, count: number, duration: number }; +type DedupedStep = { step: api.TestStep, count: number, duration: number }; -function dedupeSteps(steps: TestStepPublic[]) { +function dedupeSteps(steps: api.TestStep[]) { const result: DedupedStep[] = []; let lastResult = undefined; for (const step of steps) { From 03cf7429a4b69627a7114ff3fc984a553d05139e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 20 Dec 2024 18:04:21 -0800 Subject: [PATCH 52/73] chore(bidi): upload report.csv to azure (#34122) --- .github/workflows/tests_bidi.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index b559a87563..6be824869a 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -51,3 +51,20 @@ jobs: name: csv-report-${{ matrix.channel }} path: test-results/report.csv retention-days: 7 + + - name: Azure Login + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_BLOB_REPORTS_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_BLOB_REPORTS_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_BLOB_REPORTS_SUBSCRIPTION_ID }} + + - name: Upload report.csv to Azure + if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} + run: | + REPORT_DIR='bidi-reports' + azcopy cp "./test-results/report.csv" "https://mspwblobreport.blob.core.windows.net/\$web/$REPORT_DIR/${{ matrix.channel }}.csv" + echo "Report url: https://mspwblobreport.z1.web.core.windows.net/$REPORT_DIR/${{ matrix.channel }}.csv" + env: + AZCOPY_AUTO_LOGIN_TYPE: AZCLI From 1c8e6f0921b1cc517afb5f8549609f356e81cf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= <48261497+GauBen@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:59:50 +0100 Subject: [PATCH 53/73] docs: fixed typo (#34129) --- docs/src/test-webserver-js.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/test-webserver-js.md b/docs/src/test-webserver-js.md index aba072d56a..bf01a5cd27 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -36,7 +36,7 @@ export default defineConfig({ | `cwd` | Current working directory of the spawned process, defaults to the directory of the configuration file. | | `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. | | `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. | -| `timeout` | `How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | +| `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | ## Adding a server timeout From 08644003d2a8770045e9eb3c7fa9a0f8bb812413 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 26 Dec 2024 13:27:56 -0800 Subject: [PATCH 54/73] feat(chromium-tip-of-tree): roll to r1290 (#34144) 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 a163c7a607..f5b2495459 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1288", + "revision": "1290", "installByDefault": false, - "browserVersion": "133.0.6905.0" + "browserVersion": "133.0.6919.0" }, { "name": "firefox", From 3ec8ee7a9b9ee682f63ff155a36ea868951d8ae5 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:51:06 -0800 Subject: [PATCH 55/73] feat(chromium): roll to r1153 (#34118) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 913aac3269..7d100113c5 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-132.0.6834.46-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-132.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-132.0.6834.57-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-132.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [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 132.0.6834.46 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 132.0.6834.57 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 132.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 f5b2495459..3462c09224 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1152", + "revision": "1153", "installByDefault": true, - "browserVersion": "132.0.6834.46" + "browserVersion": "132.0.6834.57" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 49d9edde41..d2777c80e9 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,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/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,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/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,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/132.0.6834.46 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/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Safari/537.36 Edg/132.0.6834.46", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.46 Safari/537.36 Edg/132.0.6834.46", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6834.57 Safari/537.36 Edg/132.0.6834.57", "screen": { "width": 1920, "height": 1080 From 7f141b2c4202a2ea3754bbf8e8d4240e89f41641 Mon Sep 17 00:00:00 2001 From: Pengoose Date: Fri, 27 Dec 2024 18:54:16 +0900 Subject: [PATCH 56/73] feat: expect(locator).toHaveAccessibleErrorMessage (#33904) --- docs/src/api/class-locatorassertions.md | 50 +++++++ .../src/server/injected/injectedScript.ts | 4 +- .../src/server/injected/roleUtils.ts | 56 ++++++++ packages/playwright/src/matchers/expect.ts | 2 + packages/playwright/src/matchers/matchers.ts | 12 ++ packages/playwright/types/test.d.ts | 28 ++++ tests/page/expect-misc.spec.ts | 130 ++++++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 7e61e1b3a5..c2adf3afc5 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -1217,6 +1217,56 @@ Expected accessible description. * since: v1.44 +## async method: LocatorAssertions.toHaveAccessibleErrorMessage +* since: v1.50 +* langs: + - alias-java: hasAccessibleErrorMessage + +Ensures the [Locator] points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage). + +**Usage** + +```js +const locator = page.getByTestId('username-input'); +await expect(locator).toHaveAccessibleErrorMessage('Username is required.'); +``` + +```java +Locator locator = page.getByTestId("username-input"); +assertThat(locator).hasAccessibleErrorMessage("Username is required."); +``` + +```python async +locator = page.get_by_test_id("username-input") +await expect(locator).to_have_accessible_error_message("Username is required.") +``` + +```python sync +locator = page.get_by_test_id("username-input") +expect(locator).to_have_accessible_error_message("Username is required.") +``` + +```csharp +var locator = Page.GetByTestId("username-input"); +await Expect(locator).ToHaveAccessibleErrorMessageAsync("Username is required."); +``` + +### param: LocatorAssertions.toHaveAccessibleErrorMessage.errorMessage +* since: v1.50 +- `errorMessage` <[string]|[RegExp]> + +Expected accessible error message. + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-js-assertions-timeout-%% +* since: v1.50 + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.50 + +### option: LocatorAssertions.toHaveAccessibleErrorMessage.ignoreCase = %%-assertions-ignore-case-%% +* since: v1.50 + + ## async method: LocatorAssertions.toHaveAccessibleName * since: v1.44 * langs: diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index a1ffdf893c..21fcca69a4 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils'; +import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; @@ -1321,6 +1321,8 @@ export class InjectedScript { received = getElementAccessibleName(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.description') { received = getElementAccessibleDescription(element, false /* includeHidden */); + } else if (expression === 'to.have.accessible.error.message') { + received = getElementAccessibleErrorMessage(element); } else if (expression === 'to.have.role') { received = getAriaRole(element) || ''; } else if (expression === 'to.have.title') { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index cc9e6a70d0..f74c893c1b 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden: return accessibleDescription; } +// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid +const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid']; + +function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' { + const role = getAriaRole(element) || ''; + if (!role || !kAriaInvalidRoles.includes(role)) + return 'false'; + const ariaInvalid = element.getAttribute('aria-invalid'); + if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false') + return 'false'; + if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling') + return ariaInvalid; + return 'true'; +} + +function getValidityInvalid(element: Element) { + if ('validity' in element){ + const validity = element.validity as ValidityState | undefined; + return validity?.valid === false; + } + return false; +} + +export function getElementAccessibleErrorMessage(element: Element): string { + // SPEC: https://w3c.github.io/aria/#aria-errormessage + // + // TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage + const cache = cacheAccessibleErrorMessage; + let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element); + + if (accessibleErrorMessage === undefined) { + accessibleErrorMessage = ''; + + const isAriaInvalid = getAriaInvalid(element) !== 'false'; + const isValidityInvalid = getValidityInvalid(element); + if (isAriaInvalid || isValidityInvalid) { + const errorMessageId = element.getAttribute('aria-errormessage'); + const errorMessages = getIdRefs(element, errorMessageId); + // Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules. + // Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage. + const parts = errorMessages.map(errorMessage => asFlatString( + getTextAlternativeInternal(errorMessage, { + visitedElements: new Set(), + embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) }, + }) + )); + accessibleErrorMessage = parts.join(' ').trim(); + } + cache?.set(element, accessibleErrorMessage); + } + return accessibleErrorMessage; +} + type AccessibleNameOptions = { visitedElements: Set, includeHidden?: boolean, @@ -972,6 +1025,7 @@ let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheAccessibleDescription: Map | undefined; let cacheAccessibleDescriptionHidden: Map | undefined; +let cacheAccessibleErrorMessage: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; @@ -983,6 +1037,7 @@ export function beginAriaCaches() { cacheAccessibleNameHidden ??= new Map(); cacheAccessibleDescription ??= new Map(); cacheAccessibleDescriptionHidden ??= new Map(); + cacheAccessibleErrorMessage ??= new Map(); cacheIsHidden ??= new Map(); cachePseudoContentBefore ??= new Map(); cachePseudoContentAfter ??= new Map(); @@ -994,6 +1049,7 @@ export function endAriaCaches() { cacheAccessibleNameHidden = undefined; cacheAccessibleDescription = undefined; cacheAccessibleDescriptionHidden = undefined; + cacheAccessibleErrorMessage = undefined; cacheIsHidden = undefined; cachePseudoContentBefore = undefined; cachePseudoContentAfter = undefined; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0bd116e7a1..d4c3287d33 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -35,6 +35,7 @@ import { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, @@ -224,6 +225,7 @@ const customAsyncMatchers = { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 8a8089e91e..3962c0cae9 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -205,6 +205,18 @@ export function toHaveAccessibleName( } } +export function toHaveAccessibleErrorMessage( + this: ExpectMatcherState, + locator: LocatorEx, + expected: string | RegExp, + options?: { timeout?: number; ignoreCase?: boolean }, +) { + return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => { + const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); + return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout }); + }, expected, options); +} + export function toHaveAttribute( this: ExpectMatcherState, locator: LocatorEx, diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 85e3d37847..7c9cae5415 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8112,6 +8112,34 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given + * [aria errormessage](https://w3c.github.io/aria/#aria-errormessage). + * + * **Usage** + * + * ```js + * const locator = page.getByTestId('username-input'); + * await expect(locator).toHaveAccessibleErrorMessage('Username is required.'); + * ``` + * + * @param errorMessage Expected accessible error message. + * @param options + */ + toHaveAccessibleErrorMessage(errorMessage: string|RegExp, options?: { + /** + * Whether to perform case-insensitive match. + * [`ignoreCase`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-accessible-error-message-option-ignore-case) + * option takes precedence over the corresponding regular expression flag if specified. + */ + ignoreCase?: boolean; + + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given * [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 0ab5707a62..a1fb6637b1 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -491,6 +491,136 @@ test('toHaveAccessibleDescription', async ({ page }) => { await expect(page.locator('div')).toHaveAccessibleDescription('foo bar baz'); }); +test('toHaveAccessibleErrorMessage', async ({ page }) => { + await page.setContent(` +
+ +
Hello
+
This should not be considered.
+
+ `); + + const locator = page.locator('input[role="textbox"]'); + await expect(locator).toHaveAccessibleErrorMessage('Hello'); + await expect(locator).not.toHaveAccessibleErrorMessage('hello'); + await expect(locator).toHaveAccessibleErrorMessage('hello', { ignoreCase: true }); + await expect(locator).toHaveAccessibleErrorMessage(/ell\w/); + await expect(locator).not.toHaveAccessibleErrorMessage(/hello/); + await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true }); + await expect(locator).not.toHaveAccessibleErrorMessage('This should not be considered.'); +}); + +test('toHaveAccessibleErrorMessage should handle multiple aria-errormessage references', async ({ page }) => { + await page.setContent(` +
+ +
First error message.
+
Second error message.
+
This should not be considered.
+
+ `); + + const locator = page.locator('input[role="textbox"]'); + + await expect(locator).toHaveAccessibleErrorMessage('First error message. Second error message.'); + await expect(locator).toHaveAccessibleErrorMessage(/first error message./i); + await expect(locator).toHaveAccessibleErrorMessage(/second error message./i); + await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i); +}); + +test.describe('toHaveAccessibleErrorMessage should handle aria-invalid attribute', () => { + const errorMessageText = 'Error message'; + + async function setupPage(page, ariaInvalidValue: string | null) { + const ariaInvalidAttr = ariaInvalidValue === null ? '' : `aria-invalid="${ariaInvalidValue}"`; + await page.setContent(` +
+ +
${errorMessageText}
+
+ `); + return page.locator('#node'); + } + + test.describe('evaluated in false', () => { + test('no aria-invalid attribute', async ({ page }) => { + const locator = await setupPage(page, null); + await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText); + }); + test('aria-invalid="false"', async ({ page }) => { + const locator = await setupPage(page, 'false'); + await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText); + }); + test('aria-invalid="" (empty string)', async ({ page }) => { + const locator = await setupPage(page, ''); + await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText); + }); + }); + test.describe('evaluated in true', () => { + test('aria-invalid="true"', async ({ page }) => { + const locator = await setupPage(page, 'true'); + await expect(locator).toHaveAccessibleErrorMessage(errorMessageText); + }); + test('aria-invalid="foo" (unrecognized value)', async ({ page }) => { + const locator = await setupPage(page, 'foo'); + await expect(locator).toHaveAccessibleErrorMessage(errorMessageText); + }); + }); +}); + +test.describe('toHaveAccessibleErrorMessage should handle validity state with aria-invalid', () => { + const errorMessageText = 'Error message'; + + test('should show error message when validity is false and aria-invalid is true', async ({ page }) => { + await page.setContent(` +
+ +
${errorMessageText}
+
+ `); + const locator = page.locator('#node'); + await locator.fill('101'); + await expect(locator).toHaveAccessibleErrorMessage(errorMessageText); + }); + + test('should show error message when validity is true and aria-invalid is true', async ({ page }) => { + await page.setContent(` +
+ +
${errorMessageText}
+
+ `); + const locator = page.locator('#node'); + await locator.fill('99'); + await expect(locator).toHaveAccessibleErrorMessage(errorMessageText); + }); + + test('should show error message when validity is false and aria-invalid is false', async ({ page }) => { + await page.setContent(` +
+ +
${errorMessageText}
+
+ `); + const locator = page.locator('#node'); + await locator.fill('101'); + await expect(locator).toHaveAccessibleErrorMessage(errorMessageText); + }); + + test('should not show error message when validity is true and aria-invalid is false', async ({ page }) => { + await page.setContent(` +
+ +
${errorMessageText}
+
+ `); + const locator = page.locator('#node'); + await locator.fill('99'); + await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText); + }); +}); + + test('toHaveRole', async ({ page }) => { await page.setContent(`
Button!
`); await expect(page.locator('div')).toHaveRole('button'); From 4819747c85faa8cad0f33d770e1e63f6fb894d49 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 27 Dec 2024 11:00:59 +0100 Subject: [PATCH 57/73] chore: keep linting generated files (#34150) --- .eslintignore | 3 --- packages/playwright-core/src/protocol/debug.ts | 2 +- packages/playwright-core/src/protocol/validator.ts | 2 +- .../src/server/injected/recorder/clipPaths.ts | 2 +- packages/protocol/src/channels.d.ts | 1 + utils/generate_channels.js | 6 +++--- utils/generate_clip_paths.js | 1 + 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.eslintignore b/.eslintignore index fdcc76dda0..9d22f618d8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,9 +3,6 @@ test/assets/modernizr.js /packages/*/lib/ *.js /packages/playwright-core/src/generated/* -/packages/playwright-core/src/protocol/debug.ts -/packages/playwright-core/src/protocol/validator.ts -/packages/playwright-core/src/server/injected/recorder/clipPaths.ts /packages/playwright-core/src/third_party/ /packages/playwright-core/types/* /packages/playwright-ct-core/src/generated/* diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index 4f44f5941e..f29ae6be82 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -187,4 +187,4 @@ export const pausesBeforeInputActions = new Set([ 'ElementHandle.tap', 'ElementHandle.type', 'ElementHandle.uncheck' -]); \ No newline at end of file +]); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f4db833e02..9b14551fb8 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2752,4 +2752,4 @@ scheme.JsonPipeSendParams = tObject({ }); scheme.JsonPipeSendResult = tOptional(tObject({})); scheme.JsonPipeCloseParams = tOptional(tObject({})); -scheme.JsonPipeCloseResult = tOptional(tObject({})); \ No newline at end of file +scheme.JsonPipeCloseResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts index a1e016542a..1ac490843d 100644 --- a/packages/playwright-core/src/server/injected/recorder/clipPaths.ts +++ b/packages/playwright-core/src/server/injected/recorder/clipPaths.ts @@ -28,4 +28,4 @@ import type { SvgJson } from './recorder'; // eslint-disable-next-line key-spacing, object-curly-spacing, comma-spacing, quotes const svgJson: SvgJson = {"tagName":"svg","children":[{"tagName":"defs","children":[{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gripper"},"children":[{"tagName":"path","attrs":{"d":"M5 3h2v2H5zm0 4h2v2H5zm0 4h2v2H5zm4-8h2v2H9zm0 4h2v2H9zm0 4h2v2H9z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-circle-large-filled"},"children":[{"tagName":"path","attrs":{"d":"M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-inspect"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-whole-word"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M0 11H1V13H15V11H16V14H15H1H0V11Z"}},{"tagName":"path","attrs":{"d":"M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"}},{"tagName":"path","attrs":{"d":"M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-eye"},"children":[{"tagName":"path","attrs":{"d":"M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-symbol-constant"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M4 6h8v1H4V6zm8 3H4v1h8V9z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-check"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-close"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-pass"},"children":[{"tagName":"path","attrs":{"d":"M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z"}},{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z"}}]},{"tagName":"clipPath","attrs":{"width":"16","height":"16","viewBox":"0 0 16 16","fill":"currentColor","id":"icon-gist"},"children":[{"tagName":"path","attrs":{"fill-rule":"evenodd","clip-rule":"evenodd","d":"M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"}}]}]}]}; -export default svgJson; \ No newline at end of file +export default svgJson; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 100baf59f9..6f9e36f0c3 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -4983,3 +4983,4 @@ export interface JsonPipeEvents { 'message': JsonPipeMessageEvent; 'closed': JsonPipeClosedEvent; } + diff --git a/utils/generate_channels.js b/utils/generate_channels.js index 0bebcbba0c..827250ad8c 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -362,7 +362,7 @@ function writeFile(filePath, content) { fs.writeFileSync(filePath, content, 'utf8'); } -writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.d.ts'), channels_ts.join('\n')); -writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'debug.ts'), debug_ts.join('\n')); -writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'validator.ts'), validator_ts.join('\n')); +writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.d.ts'), channels_ts.join('\n') + '\n'); +writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'debug.ts'), debug_ts.join('\n') + '\n'); +writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'validator.ts'), validator_ts.join('\n') + '\n'); process.exit(hasChanges ? 1 : 0); diff --git a/utils/generate_clip_paths.js b/utils/generate_clip_paths.js index 83d26a905c..184b71d36a 100644 --- a/utils/generate_clip_paths.js +++ b/utils/generate_clip_paths.js @@ -95,6 +95,7 @@ const iconNames = [ `// eslint-disable-next-line key-spacing, object-curly-spacing, comma-spacing, quotes`, `const svgJson: SvgJson = ${JSON.stringify(svgJson)};`, `export default svgJson;`, + '', ].join('\n'); fs.writeFileSync(outFile, code, 'utf-8'); })(); From 9dbe63636d3d17dd834f1324538e351ea6931cdd Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 30 Dec 2024 18:00:10 +0000 Subject: [PATCH 58/73] fix(routeWebSocket): should work after context reuse (#34165) --- .../src/server/browserContext.ts | 8 +++- .../dispatchers/browserContextDispatcher.ts | 2 +- .../src/server/dispatchers/pageDispatcher.ts | 2 +- .../dispatchers/webSocketRouteDispatcher.ts | 25 +++++------ packages/playwright-core/src/server/page.ts | 8 ++-- tests/library/browsercontext-reuse.spec.ts | 41 ++++++++++++++++++- 6 files changed, 64 insertions(+), 22 deletions(-) diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index fc20c52bb5..8a835d3726 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -314,6 +314,10 @@ export abstract class BrowserContext extends SdkObject { return this.doSetHTTPCredentials(httpCredentials); } + hasBinding(name: string) { + return this._pageBindings.has(name); + } + async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise { if (this._pageBindings.has(name)) throw new Error(`Function "${name}" has been already registered`); @@ -414,8 +418,8 @@ export abstract class BrowserContext extends SdkObject { this._options.httpCredentials = { username, password: password || '' }; } - async addInitScript(source: string) { - const initScript = new InitScript(source); + async addInitScript(source: string, name?: string) { + const initScript = new InitScript(source, false /* internal */, name); this.initScripts.push(initScript); await this.doAddInitScript(initScript); } diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index c6ffce49f7..3579f4b3bb 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -288,7 +288,7 @@ export class BrowserContextDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(this, this._context); + await WebSocketRouteDispatcher.installIfNeeded(this._context); } async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 5e3b73a73f..6e8c47929b 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -191,7 +191,7 @@ export class PageDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page); + await WebSocketRouteDispatcher.installIfNeeded(this._page); } async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts index 87f469b7d0..bcbc89fe03 100644 --- a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -18,7 +18,7 @@ import type { BrowserContext } from '../browserContext'; import type { Frame } from '../frames'; import { Page } from '../page'; import type * as channels from '@protocol/channels'; -import { Dispatcher } from './dispatcher'; +import { Dispatcher, existingDispatcher } from './dispatcher'; import { createGuid, urlMatches } from '../../utils'; import { PageDispatcher } from './pageDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; @@ -26,9 +26,6 @@ import * as webSocketMockSource from '../../generated/webSocketMockSource'; import type * as ws from '../injected/webSocketMock'; import { eventsHelper } from '../../utils/eventsHelper'; -const kBindingInstalledSymbol = Symbol('webSocketRouteBindingInstalled'); -const kInitScriptInstalledSymbol = Symbol('webSocketRouteInitScriptInstalled'); - export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel { _type_WebSocketRoute = true; private _id: string; @@ -57,18 +54,18 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann (scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this }); } - static async installIfNeeded(contextDispatcher: BrowserContextDispatcher, target: Page | BrowserContext) { + static async installIfNeeded(target: Page | BrowserContext) { + const kBindingName = '__pwWebSocketBinding'; const context = target instanceof Page ? target.context() : target; - if (!(context as any)[kBindingInstalledSymbol]) { - (context as any)[kBindingInstalledSymbol] = true; - - await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => { + if (!context.hasBinding(kBindingName)) { + await context.exposeBinding(kBindingName, false, (source, payload: ws.BindingPayload) => { if (payload.type === 'onCreate') { - const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page); + const contextDispatcher = existingDispatcher(context); + const pageDispatcher = contextDispatcher ? PageDispatcher.fromNullable(contextDispatcher, source.page) : undefined; let scope: PageDispatcher | BrowserContextDispatcher | undefined; if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url)) scope = pageDispatcher; - else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) + else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) scope = contextDispatcher; if (scope) { new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame); @@ -91,15 +88,15 @@ export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, chann }); } - if (!(target as any)[kInitScriptInstalledSymbol]) { - (target as any)[kInitScriptInstalledSymbol] = true; + const kInitScriptName = 'webSocketMockSource'; + if (!target.initScripts.find(s => s.name === kInitScriptName)) { await target.addInitScript(` (() => { const module = {}; ${webSocketMockSource.source} (module.exports.inject())(globalThis); })(); - `); + `, kInitScriptName); } } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index fe483b8347..9b85837b65 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -564,8 +564,8 @@ export class Page extends SdkObject { await this._delegate.bringToFront(); } - async addInitScript(source: string) { - const initScript = new InitScript(source); + async addInitScript(source: string, name?: string) { + const initScript = new InitScript(source, false /* internal */, name); this.initScripts.push(initScript); await this._delegate.addInitScript(initScript); } @@ -953,8 +953,9 @@ function addPageBinding(playwrightBinding: string, bindingName: string, needsHan export class InitScript { readonly source: string; readonly internal: boolean; + readonly name?: string; - constructor(source: string, internal?: boolean) { + constructor(source: string, internal?: boolean, name?: string) { const guid = createGuid(); this.source = `(() => { globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; @@ -965,6 +966,7 @@ export class InitScript { ${source} })();`; this.internal = !!internal; + this.name = name; } } diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 30319f0aad..2d65d472b3 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -15,7 +15,7 @@ */ import { browserTest, expect } from '../config/browserTest'; -import type { BrowserContext } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; const test = browserTest.extend<{ reusedContext: () => Promise }>({ reusedContext: async ({ browserType, browser }, use) => { @@ -287,3 +287,42 @@ test('should continue issuing events after closing the reused page', async ({ re ]); } }); + +test('should work with routeWebSocket', async ({ reusedContext, server, browser }, testInfo) => { + async function setup(page: Page, suffix: string) { + await page.routeWebSocket(/ws1/, ws => { + ws.onMessage(message => { + ws.send('page-mock-' + suffix); + }); + }); + await page.context().routeWebSocket(/.*/, ws => { + ws.onMessage(message => { + ws.send('context-mock-' + suffix); + }); + }); + await page.goto('about:blank'); + await page.evaluate(({ port }) => { + window.log = []; + (window as any).ws1 = new WebSocket('ws://localhost:' + port + '/ws1'); + (window as any).ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`)); + (window as any).ws2 = new WebSocket('ws://localhost:' + port + '/ws2'); + (window as any).ws2.addEventListener('message', event => window.log.push(`ws2:${event.data}`)); + }, { port: server.PORT }); + } + + let context = await reusedContext(); + let page = await context.newPage(); + await setup(page, 'before'); + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`]); + await page.evaluate(() => (window as any).ws2.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`, `ws2:context-mock-before`]); + + context = await reusedContext(); + page = context.pages()[0]; + await setup(page, 'after'); + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`]); + await page.evaluate(() => (window as any).ws2.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`, `ws2:context-mock-after`]); +}); From cab2bc4e2a1e8f6d5c4f958e68bfa240a622cf98 Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Mon, 30 Dec 2024 19:06:00 +0100 Subject: [PATCH 59/73] Combine file name and test name to a single identifier for CSV export of BiDi results (#34172) --- tests/bidi/csvReporter.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts index 4986a736c3..1da6b6b697 100644 --- a/tests/bidi/csvReporter.ts +++ b/tests/bidi/csvReporter.ts @@ -41,15 +41,14 @@ class CsvReporter implements Reporter { } onEnd(result: FullResult) { - const rows = [['File Name', 'Test Name', 'Expected Status', 'Status', 'Error Message']]; + const rows = [['Test Name', 'Expected Status', 'Status', 'Error Message']]; for (const project of this._suite.suites) { for (const file of project.suites) { for (const test of file.allTests()) { if (test.ok()) continue; const row = []; - row.push(file.title); - row.push(csvEscape(test.title)); + row.push(csvEscape(`${file.title} :: ${test.title}`)); row.push(test.expectedStatus); row.push(test.outcome()); const result = test.results.find(r => r.error); From cd32d1b08c2d3f26abfaa1ae4222a0d5221e4f80 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 30 Dec 2024 18:45:49 +0000 Subject: [PATCH 60/73] fix(test runner): apply `--last-failed` after sharding (#34166) --- packages/playwright/src/common/config.ts | 1 + packages/playwright/src/runner/lastRun.ts | 2 +- packages/playwright/src/runner/loadUtils.ts | 4 +++ tests/playwright-test/runner.spec.ts | 32 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index a86fbb5157..443cb58319 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -56,6 +56,7 @@ export class FullConfigInternal { cliFailOnFlakyTests?: boolean; cliLastFailed?: boolean; testIdMatcher?: Matcher; + lastFailedTestIdMatcher?: Matcher; defineConfigWasUsed = false; globalSetups: string[] = []; diff --git a/packages/playwright/src/runner/lastRun.ts b/packages/playwright/src/runner/lastRun.ts index 407543041e..2152f977cf 100644 --- a/packages/playwright/src/runner/lastRun.ts +++ b/packages/playwright/src/runner/lastRun.ts @@ -43,7 +43,7 @@ export class LastRunReporter implements ReporterV2 { return; try { const lastRunInfo = JSON.parse(await fs.promises.readFile(this._lastRunFile, 'utf8')) as LastRunInfo; - this._config.testIdMatcher = id => lastRunInfo.failedTests.includes(id); + this._config.lastFailedTestIdMatcher = id => lastRunInfo.failedTests.includes(id); } catch { } } diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index 1315eea6e4..62799747b6 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -194,6 +194,10 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho filterTestsRemoveEmptySuites(rootSuite, test => testsInThisShard.has(test)); } + // Explicitly apply --last-failed filter after sharding. + if (config.lastFailedTestIdMatcher) + filterByTestIds(rootSuite, config.lastFailedTestIdMatcher); + // Now prepend dependency projects without filtration. { // Filtering 'only' and sharding might have reduced the number of top-level projects. diff --git a/tests/playwright-test/runner.spec.ts b/tests/playwright-test/runner.spec.ts index dc88187229..dece006a06 100644 --- a/tests/playwright-test/runner.spec.ts +++ b/tests/playwright-test/runner.spec.ts @@ -841,3 +841,35 @@ test('should run last failed tests', async ({ runInlineTest }) => { expect(result2.passed).toBe(0); expect(result2.failed).toBe(1); }); + +test('should run last failed tests in a shard', async ({ runInlineTest }) => { + const workspace = { + 'a.spec.js': ` + import { test, expect } from '@playwright/test'; + test('pass-a', async () => {}); + test('fail-a', async () => { + expect(1).toBe(2); + }); + `, + 'b.spec.js': ` + import { test, expect } from '@playwright/test'; + test('pass-b', async () => {}); + test('fail-b', async () => { + expect(1).toBe(2); + }); + `, + }; + const result1 = await runInlineTest(workspace, { shard: '2/2' }); + expect(result1.exitCode).toBe(1); + expect(result1.passed).toBe(1); + expect(result1.failed).toBe(1); + expect(result1.output).toContain('b.spec.js:3:11 › pass-b'); + expect(result1.output).toContain('b.spec.js:4:11 › fail-b'); + + const result2 = await runInlineTest(workspace, { shard: '2/2' }, {}, { additionalArgs: ['--last-failed'] }); + expect(result2.exitCode).toBe(1); + expect(result2.passed).toBe(0); + expect(result2.failed).toBe(1); + expect(result2.output).not.toContain('b.spec.js:3:11 › pass-b'); + expect(result2.output).toContain('b.spec.js:4:11 › fail-b'); +}); From 940230d43a272d6e5758dd88f0f6d9600176b73d Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Mon, 30 Dec 2024 23:48:22 +0100 Subject: [PATCH 61/73] Use csvReporter as well when running BiDi tests locally (#34167) --- tests/bidi/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 8dbe5b2b9d..5fb370482c 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -42,6 +42,7 @@ const reporters = () => { ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ] : [ ['html', { open: 'on-failure' }], + ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ['./expectationReporter', { rebase: false }], ]; return result; From b2cbe7f2ec6abf43f0e441fe2dae566cb7e9fd30 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 31 Dec 2024 11:22:10 +0100 Subject: [PATCH 62/73] chore(roll): roll WebKit to r2121 (#34179) --- 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 3462c09224..6c4af54313 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2120", + "revision": "2121", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", From 7769010e6e146a81504fa921fa3931867dbbc505 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 31 Dec 2024 13:18:25 -0800 Subject: [PATCH 63/73] chore(bidi): mark test expected to timeout as fixme (#34176) --- tests/bidi/csvReporter.ts | 14 ++++++++++---- tests/config/browserTest.ts | 3 +-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts index 1da6b6b697..f227550ef3 100644 --- a/tests/bidi/csvReporter.ts +++ b/tests/bidi/csvReporter.ts @@ -45,15 +45,21 @@ class CsvReporter implements Reporter { for (const project of this._suite.suites) { for (const file of project.suites) { for (const test of file.allTests()) { - if (test.ok()) + // Report fixme tests as failing. + const fixme = test.annotations.find(a => a.type === 'fixme'); + if (test.ok() && !fixme) continue; const row = []; row.push(csvEscape(`${file.title} :: ${test.title}`)); row.push(test.expectedStatus); row.push(test.outcome()); - const result = test.results.find(r => r.error); - const errorMessage = stripAnsi(result?.error?.message.replace(/\s+/g, ' ').trim().substring(0, 1024)); - row.push(csvEscape(errorMessage ?? '')); + if (fixme) { + row.push('fixme' + (fixme.description ? `: ${fixme.description}` : '')); + } else { + const result = test.results.find(r => r.error); + const errorMessage = stripAnsi(result?.error?.message.replace(/\s+/g, ' ').trim().substring(0, 1024)); + row.push(csvEscape(errorMessage ?? '')); + } rows.push(row); } } diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 8477359407..7836b2e38c 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -188,8 +188,7 @@ const test = baseTest.extend }, { scope: 'worker' }], autoSkipBidiTest: [async ({ bidiTestSkipPredicate }, run) => { - if (bidiTestSkipPredicate(test.info())) - test.skip(true); + test.fixme(bidiTestSkipPredicate(test.info()), 'marked as timeout in bidi expectations'); await run(); }, { auto: true, scope: 'test' }], }); From da52befea076312638b800891de7bbb59485f60a Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:16:04 -0800 Subject: [PATCH 64/73] feat(chromium-tip-of-tree): roll to r1291 (#34182) 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 6c4af54313..03a135dfa7 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1290", + "revision": "1291", "installByDefault": false, - "browserVersion": "133.0.6919.0" + "browserVersion": "133.0.6929.0" }, { "name": "firefox", From 546b7b702c41711baffce9b0c627adc584aabf75 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:16:46 -0800 Subject: [PATCH 65/73] feat(webkit): roll to r2122 (#34180) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/webkit/protocol.d.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 03a135dfa7..9bafa13f3b 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2121", + "revision": "2122", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 7c279f9e1e..9abd47bcfd 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -6689,6 +6689,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the * Cookie Same-Site policy. */ sameSite: CookieSameSitePolicy; + /** + * Cookie partition key. If null and partitioned property is true, then key must be computed. + */ + partitionKey?: string; } /** * Accessibility Node @@ -7073,6 +7077,10 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the */ export type setCookieParameters = { cookie: Cookie; + /** + * If true, then cookie's partition key should be set. + */ + shouldPartition?: boolean; } export type setCookieReturnValue = { } From acdd666d9530277f3a2561f5591b9a8cf1780ce8 Mon Sep 17 00:00:00 2001 From: David Gahnassia Date: Thu, 2 Jan 2025 07:17:22 +0200 Subject: [PATCH 66/73] docs(test-fixtures): removed redundancy (#34185) --- docs/src/test-fixtures-js.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/test-fixtures-js.md b/docs/src/test-fixtures-js.md index 1d19d0acdb..9bdd4391ad 100644 --- a/docs/src/test-fixtures-js.md +++ b/docs/src/test-fixtures-js.md @@ -695,7 +695,7 @@ test('passes', async ({ database, page, a11y }) => { ## Box fixtures -Usually, custom fixtures are reported as separate steps in in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it. +Usually, custom fixtures are reported as separate steps in the UI mode, Trace Viewer and various test reports. They also appear in error messages from the test runner. For frequently-used fixtures, this can mean lots of noise. You can stop the fixtures steps from being shown in the UI by "boxing" it. ```js import { test as base } from '@playwright/test'; From 175f05cafccfc7c730cf5e15102c5fb87e337724 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 2 Jan 2025 16:04:51 +0100 Subject: [PATCH 67/73] test: increase page-event-crash timeout (#34178) --- tests/library/page-event-crash.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/library/page-event-crash.spec.ts b/tests/library/page-event-crash.spec.ts index 1bf9c396dd..d185fa5a19 100644 --- a/tests/library/page-event-crash.spec.ts +++ b/tests/library/page-event-crash.spec.ts @@ -32,6 +32,10 @@ const test = testBase.extend<{ crash: () => void }, { dummy: string }>({ dummy: ['', { scope: 'worker' }], }); +test.beforeEach(({ platform, browserName }) => { + test.slow(platform === 'linux' && browserName === 'webkit', 'WebKit/Linux tests are consistently slower on some Linux environments. Most likely WebContent process is not getting terminated properly and is causing the slowdown.'); +}); + test('should emit crash event when page crashes', async ({ page, crash }) => { await page.setContent(`
This page should crash
`); crash(); From 04a3574f8054f00c34129a2b1b8acc2a58594956 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 2 Jan 2025 17:48:59 +0100 Subject: [PATCH 68/73] feat(reporter): report `TestStep#attachments` (#34037) --- docs/src/test-reporter-api/class-teststep.md | 10 ++++ packages/html-reporter/src/links.tsx | 5 +- .../html-reporter/src/testCaseView.spec.tsx | 3 + packages/html-reporter/src/testFileView.tsx | 2 +- packages/html-reporter/src/testResultView.tsx | 27 +++++---- packages/html-reporter/src/types.d.ts | 1 + packages/playwright/src/common/ipc.ts | 1 + .../playwright/src/isomorphic/teleReceiver.ts | 17 +++++- packages/playwright/src/reporters/html.ts | 18 ++++-- .../playwright/src/reporters/teleEmitter.ts | 5 +- packages/playwright/src/runner/dispatcher.ts | 8 +++ packages/playwright/src/worker/testInfo.ts | 55 +++++++++++++------ packages/playwright/types/testReporter.d.ts | 27 +++++++++ .../playwright-test/playwright.trace.spec.ts | 6 +- tests/playwright-test/reporter-html.spec.ts | 26 +++++++++ tests/playwright-test/reporter.spec.ts | 31 +++++++++++ .../to-have-screenshot.spec.ts | 28 ++++++++-- tests/playwright-test/ui-mode-trace.spec.ts | 49 ++++++++++++++++- 18 files changed, 265 insertions(+), 54 deletions(-) diff --git a/docs/src/test-reporter-api/class-teststep.md b/docs/src/test-reporter-api/class-teststep.md index 43b8474abe..ef16e4849a 100644 --- a/docs/src/test-reporter-api/class-teststep.md +++ b/docs/src/test-reporter-api/class-teststep.md @@ -50,6 +50,16 @@ Start time of this particular test step. List of steps inside this step. +## property: TestStep.attachments +* since: v1.50 +- type: <[Array]<[Object]>> + - `name` <[string]> Attachment name. + - `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. + - `path` ?<[string]> Optional path on the filesystem to the attached file. + - `body` ?<[Buffer]> Optional attachment body used instead of a file. + +The list of files or buffers attached in the step execution through [`method: TestInfo.attach`]. + ## property: TestStep.title * since: v1.10 - type: <[string]> diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index b8db4c0e9e..5f199568b5 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{ export const AttachmentLink: React.FunctionComponent<{ attachment: TestAttachment, + result: TestResult, href?: string, linkName?: string, openInNewTab?: boolean, -}> = ({ attachment, href, linkName, openInNewTab }) => { - const isAnchored = useIsAnchored('attachment-' + attachment.name); +}> = ({ attachment, result, href, linkName, openInNewTab }) => { + const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path &&
{linkName || attachment.name}} diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index b7a9f9405b..7cc9f8991c 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -37,8 +37,10 @@ const result: TestResult = { duration: 10, location: { file: 'test.spec.ts', line: 82, column: 0 }, steps: [], + attachments: [], count: 1, }], + attachments: [], }], attachments: [], status: 'passed', @@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = { location: { file: 'test.spec.ts', line: 62, column: 0 }, count: 1, steps: [], + attachments: [1], }], attachments: [{ name: 'first attachment', diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index f8fad1d646..00ea004136 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined { for (const result of test.results) { for (const attachment of result.attachments) { if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) - return {image()}; + return {image()}; } } } diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 410677cb02..9dcdf29092 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -32,7 +32,7 @@ interface ImageDiffWithAnchors extends ImageDiff { anchors: string[]; } -function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors[] { +function groupImageDiffs(screenshots: Set, result: TestResult): ImageDiffWithAnchors[] { const snapshotNameToImageDiff = new Map(); for (const attachment of screenshots) { const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); @@ -45,7 +45,7 @@ function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; snapshotNameToImageDiff.set(snapshotName, imageDiff); } - imageDiff.anchors.push(`attachment-${attachment.name}`); + imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`); if (category === 'actual') imageDiff.actual = { attachment }; if (category === 'expected') @@ -72,15 +72,15 @@ export const TestResultView: React.FC<{ result: TestResult, }> = ({ test, result }) => { const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { - const attachments = result?.attachments || []; + const attachments = result.attachments; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); - const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`); + const screenshotAnchors = [...screenshots].map(a => `attachment-${attachments.indexOf(a)}`); const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); const otherAttachments = new Set(attachments); [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); - const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); - const diffs = groupImageDiffs(screenshots); + const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`); + const diffs = groupImageDiffs(screenshots, result); const errors = classifyErrors(result.errors, diffs); return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; }, [result]); @@ -107,11 +107,11 @@ export const TestResultView: React.FC<{ {!!screenshots.length && {screenshots.map((a, i) => { - return + return - + ; })} } @@ -121,7 +121,7 @@ export const TestResultView: React.FC<{ - {traces.map((a, i) => )} + {traces.map((a, i) => )}
} } @@ -130,14 +130,14 @@ export const TestResultView: React.FC<{ - +
)} } {!!otherAttachments.size && {[...otherAttachments].map((a, i) => - - + + )} } @@ -174,10 +174,9 @@ const StepTreeItem: React.FC<{ step: TestStep; depth: number, }> = ({ test, step, result, depth }) => { - const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1]; return {msToString(step.duration)} - {attachmentName && { evt.stopPropagation(); }}>{icons.attachment()}} + {step.attachments.length > 0 && { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {step.title} {step.count > 1 && <> ✕ {step.count}} diff --git a/packages/html-reporter/src/types.d.ts b/packages/html-reporter/src/types.d.ts index 733e88e8b9..7a99184739 100644 --- a/packages/html-reporter/src/types.d.ts +++ b/packages/html-reporter/src/types.d.ts @@ -108,5 +108,6 @@ export type TestStep = { snippet?: string; error?: string; steps: TestStep[]; + attachments: number[]; count: number; }; diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 909df3dc8f..76ee996216 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -75,6 +75,7 @@ export type AttachmentPayload = { path?: string; body?: string; contentType: string; + stepId?: string; }; export type TestInfoErrorImpl = TestInfoError & { diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index f96547d427..1d41b793cd 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -108,6 +108,7 @@ export type JsonTestStepEnd = { id: string; duration: number; error?: reporterTypes.TestError; + attachments?: number[]; // index of JsonTestResultEnd.attachments }; export type JsonFullResult = { @@ -249,7 +250,7 @@ export class TeleReporterReceiver { const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined; const location = this._absoluteLocation(payload.location); - const step = new TeleTestStep(payload, parentStep, location); + const step = new TeleTestStep(payload, parentStep, location, result); if (parentStep) parentStep.steps.push(step); else @@ -262,6 +263,7 @@ export class TeleReporterReceiver { const test = this._tests.get(testId)!; const result = test.results.find(r => r._id === resultId)!; const step = result._stepMap.get(payload.id)!; + step._endPayload = payload; step.duration = payload.duration; step.error = payload.error; this._reporter.onStepEnd?.(test, result, step); @@ -512,15 +514,20 @@ class TeleTestStep implements reporterTypes.TestStep { parent: reporterTypes.TestStep | undefined; duration: number = -1; steps: reporterTypes.TestStep[] = []; + error: reporterTypes.TestError | undefined; + + private _result: TeleTestResult; + _endPayload?: JsonTestStepEnd; private _startTime: number = 0; - constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) { + constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined, result: TeleTestResult) { this.title = payload.title; this.category = payload.category; this.location = location; this.parent = parentStep; this._startTime = payload.startTime; + this._result = result; } titlePath() { @@ -535,6 +542,10 @@ class TeleTestStep implements reporterTypes.TestStep { set startTime(value: Date) { this._startTime = +value; } + + get attachments() { + return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? []; + } } export class TeleTestResult implements reporterTypes.TestResult { @@ -550,7 +561,7 @@ export class TeleTestResult implements reporterTypes.TestResult { errors: reporterTypes.TestResult['errors'] = []; error: reporterTypes.TestResult['error']; - _stepMap: Map = new Map(); + _stepMap = new Map(); _id: string; private _startTime: number = 0; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 75d345e319..62158eef6d 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -505,7 +505,7 @@ class HtmlBuilder { duration: result.duration, startTime: result.startTime.toISOString(), retry: result.retry, - steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)), + steps: dedupeSteps(result.steps).map(s => this._createTestStep(s, result)), errors: formatResultFailure(test, result, '', true).map(error => error.message), status: result.status, attachments: this._serializeAttachments([ @@ -515,20 +515,26 @@ class HtmlBuilder { }; } - private _createTestStep(dedupedStep: DedupedStep): TestStep { + private _createTestStep(dedupedStep: DedupedStep, result: api.TestResult): TestStep { const { step, duration, count } = dedupedStep; - const result: TestStep = { + const testStep: TestStep = { title: step.title, startTime: step.startTime.toISOString(), duration, - steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)), + steps: dedupeSteps(step.steps).map(s => this._createTestStep(s, result)), + attachments: step.attachments.map(s => { + const index = result.attachments.indexOf(s); + if (index === -1) + throw new Error('Unexpected, attachment not found'); + return index; + }), location: this._relativeLocation(step.location), error: step.error?.message, count }; if (step.location) - this._stepsInFile.set(step.location.file, result); - return result; + this._stepsInFile.set(step.location.file, testStep); + return testStep; } private _relativeLocation(location: api.Location | undefined): api.Location | undefined { diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index f56178114d..0ec92ae9ac 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -100,7 +100,7 @@ export class TeleReporterEmitter implements ReporterV2 { params: { testId: test.id, resultId: (result as any)[this._idSymbol], - step: this._serializeStepEnd(step) + step: this._serializeStepEnd(step, result) } }); } @@ -251,11 +251,12 @@ export class TeleReporterEmitter implements ReporterV2 { }; } - private _serializeStepEnd(step: reporterTypes.TestStep): teleReceiver.JsonTestStepEnd { + private _serializeStepEnd(step: reporterTypes.TestStep, result: reporterTypes.TestResult): teleReceiver.JsonTestStepEnd { return { id: (step as any)[this._idSymbol], duration: step.duration, error: step.error, + attachments: step.attachments.map(a => result.attachments.indexOf(a)), }; } diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index 98e0ec1546..534fe7eb4a 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -320,6 +320,7 @@ class JobDispatcher { startTime: new Date(params.wallTime), duration: -1, steps: [], + attachments: [], location: params.location, }; steps.set(params.stepId, step); @@ -361,6 +362,13 @@ class JobDispatcher { body: params.body !== undefined ? Buffer.from(params.body, 'base64') : undefined }; data.result.attachments.push(attachment); + if (params.stepId) { + const step = data.steps.get(params.stepId); + if (step) + step.attachments.push(attachment); + else + this._reporter.onStdErr?.('Internal error: step id not found: ' + params.stepId); + } } private _failTestWithErrors(test: TestCase, errors: TestError[]) { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 8b965e0a14..6577e19d0d 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -17,6 +17,7 @@ import fs from 'fs'; import path from 'path'; import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; +import type { ExpectZone } from 'playwright-core/lib/utils'; import type { TestInfo, TestStatus, FullProject } from '../../types/test'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; @@ -26,12 +27,12 @@ import type { Annotation, FullConfigInternal, FullProjectInternal } from '../com import type { FullConfig, Location } from '../../types/testReporter'; import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util'; import { TestTracing } from './testTracing'; -import type { Attachment } from './testTracing'; import type { StackFrame } from '@protocol/channels'; import { testInfoError } from './util'; export interface TestStepInternal { - complete(result: { error?: Error | unknown, attachments?: Attachment[], suggestedRebaseline?: string }): void; + complete(result: { error?: Error | unknown, suggestedRebaseline?: string }): void; + attachmentIndices: number[]; stepId: string; title: string; category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; @@ -69,6 +70,7 @@ export class TestInfoImpl implements TestInfo { readonly _projectInternal: FullProjectInternal; readonly _configInternal: FullConfigInternal; private readonly _steps: TestStepInternal[] = []; + private readonly _stepMap = new Map(); _onDidFinishTestFunction: (() => Promise) | undefined; _hasNonRetriableError = false; _hasUnhandledError = false; @@ -193,7 +195,7 @@ export class TestInfoImpl implements TestInfo { this._attachmentsPush = this.attachments.push.bind(this.attachments); this.attachments.push = (...attachments: TestInfo['attachments']) => { for (const a of attachments) - this._attach(a.name, a); + this._attach(a, this._expectStepId() ?? this._parentStep()?.stepId); return this.attachments.length; }; @@ -238,7 +240,16 @@ export class TestInfoImpl implements TestInfo { } } - _addStep(data: Omit, parentStep?: TestStepInternal): TestStepInternal { + private _parentStep() { + return zones.zoneData('stepZone') + ?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent. + } + + private _expectStepId() { + return zones.zoneData('expectZone')?.stepId; + } + + _addStep(data: Omit, parentStep?: TestStepInternal): TestStepInternal { const stepId = `${data.category}@${++this._lastStepId}`; if (data.isStage) { @@ -246,11 +257,7 @@ export class TestInfoImpl implements TestInfo { parentStep = this._findLastStageStep(this._steps); } else { if (!parentStep) - parentStep = zones.zoneData('stepZone'); - if (!parentStep) { - // If no parent step on stack, assume the current stage as parent. - parentStep = this._findLastStageStep(this._steps); - } + parentStep = this._parentStep(); } const filteredStack = filteredStackTrace(captureRawStack()); @@ -261,10 +268,12 @@ export class TestInfoImpl implements TestInfo { } data.location = data.location || filteredStack[0]; + const attachmentIndices: number[] = []; const step: TestStepInternal = { stepId, ...data, steps: [], + attachmentIndices, complete: result => { if (step.endWallTime) return; @@ -301,11 +310,13 @@ 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); + const attachments = attachmentIndices.map(i => this.attachments[i]); + this._tracing.appendAfterActionForStep(stepId, errorForTrace, attachments); } }; const parentStepList = parentStep ? parentStep.steps : this._steps; parentStepList.push(step); + this._stepMap.set(stepId, step); const payload: StepBeginPayload = { testId: this.testId, stepId, @@ -400,23 +411,33 @@ export class TestInfoImpl implements TestInfo { // ------------ TestInfo methods ------------ async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { - this._attach(name, await normalizeAndSaveAttachment(this.outputPath(), name, options)); - } - - private _attach(name: string, attachment: TestInfo['attachments'][0]) { const step = this._addStep({ title: `attach "${name}"`, category: 'attach', }); - this._attachmentsPush(attachment); + this._attach(await normalizeAndSaveAttachment(this.outputPath(), name, options), step.stepId); + step.complete({}); + } + + private _attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) { + const index = this._attachmentsPush(attachment) - 1; + if (stepId) { + this._stepMap.get(stepId)!.attachmentIndices.push(index); + } else { + // trace viewer has no means of representing attachments outside of a step, so we create an artificial action + const callId = `attach@${++this._lastStepId}`; + this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, `attach "${attachment.name}"`, undefined, []); + this._tracing.appendAfterActionForStep(callId, undefined, [attachment]); + } + this._onAttach({ testId: this.testId, name: attachment.name, contentType: attachment.contentType, path: attachment.path, - body: attachment.body?.toString('base64') + body: attachment.body?.toString('base64'), + stepId, }); - step.complete({ attachments: [attachment] }); } outputPath(...pathSegments: string[]){ diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index 04cf03287f..3f3a43984e 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -691,6 +691,33 @@ export interface TestStep { */ titlePath(): Array; + /** + * The list of files or buffers attached in the step execution through + * [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach). + */ + attachments: Array<{ + /** + * Attachment name. + */ + name: string; + + /** + * Content type of this attachment to properly present in the report, for example `'application/json'` or + * `'image/png'`. + */ + contentType: string; + + /** + * Optional path on the filesystem to the attached file. + */ + path?: string; + + /** + * Optional attachment body used instead of a file. + */ + body?: Buffer; + }>; + /** * Step category to differentiate steps with different origin and verbosity. Built-in categories are: * - `hook` for fixtures and hooks initialization and teardown diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 5c5d6c304a..9f06e8f3b4 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -540,7 +540,7 @@ test('should include attachments by default', async ({ runInlineTest, server }, contentType: 'text/plain', sha1: expect.any(String), }]); - expect([...trace.resources.keys()].filter(f => f.startsWith('resources/'))).toHaveLength(1); + expect([...trace.resources.keys()]).toContain(`resources/${trace.actions[1].attachments[0].sha1}`); }); test('should opt out of attachments', async ({ runInlineTest, server }, testInfo) => { @@ -566,7 +566,7 @@ test('should opt out of attachments', async ({ runInlineTest, server }, testInfo 'After Hooks', ]); expect(trace.actions[1].attachments).toEqual(undefined); - expect([...trace.resources.keys()].filter(f => f.startsWith('resources/'))).toHaveLength(0); + expect([...trace.resources.keys()].filter(f => f.startsWith('resources/') && !f.startsWith('resources/src@'))).toHaveLength(0); }); test('should record with custom page fixture', async ({ runInlineTest }, testInfo) => { @@ -761,7 +761,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-download-page', 'trace.zip')); - const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); + const attachedScreenshots = trace.actions.flatMap(a => a.attachments); // One screenshot for the page, no screenshot for the download page since it should have failed. expect(attachedScreenshots.length).toBe(1); }); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 556d12e8a2..7c7c60836f 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -941,6 +941,32 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(attachment).toBeInViewport(); }); + test('steps with internal attachments have links', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passing', async ({ page }, testInfo) => { + for (let i = 0; i < 100; i++) + await testInfo.attach('spacer', { body: 'content' }); + + await test.step('step', async () => { + testInfo.attachments.push({ name: 'attachment', body: 'content', contentType: 'text/plain' }); + }) + + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + + await showReport(); + await page.getByRole('link', { name: 'passing' }).click(); + + const attachment = page.getByText('attachment', { exact: true }); + await expect(attachment).not.toBeInViewport(); + await page.getByLabel('step').getByTitle('link to attachment').click(); + await expect(attachment).toBeInViewport(); + }); + test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'helper.ts': ` diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index f036e3e494..d91702620a 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Reporter, TestCase, TestResult, TestStep } from '../../packages/playwright-test/reporter'; import { test, expect } from './playwright-test-fixtures'; const smallReporterJS = ` @@ -703,3 +704,33 @@ onEnd onExit `); }); + +test('step attachments are referentially equal to result attachments', async ({ runInlineTest }) => { + class TestReporter implements Reporter { + onStepEnd(test: TestCase, result: TestResult, step: TestStep) { + console.log('%%%', JSON.stringify({ + title: step.title, + attachments: step.attachments.map(a => result.attachments.indexOf(a)), + })); + } + } + const result = await runInlineTest({ + 'reporter.ts': `module.exports = ${TestReporter.toString()}`, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({}, testInfo) => { + await test.step('step', async () => { + testInfo.attachments.push({ name: 'attachment', body: Buffer.from('content') }); + }); + }); + `, + }, { 'reporter': '', 'workers': 1 }); + + const steps = result.outputLines.map(line => JSON.parse(line)); + expect(steps).toEqual([ + { title: 'Before Hooks', attachments: [] }, + { title: 'step', attachments: [0] }, + { title: 'After Hooks', attachments: [] }, + ]); +}); diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index 83642bd19e..3afa7a8d90 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -263,11 +263,7 @@ test('should report toHaveScreenshot step with expectation name in title', async `end browserContext.newPage`, `end fixture: page`, `end Before Hooks`, - `end attach "foo-expected.png"`, - `end attach "foo-actual.png"`, `end expect.toHaveScreenshot(foo.png)`, - `end attach "is-a-test-1-expected.png"`, - `end attach "is-a-test-1-actual.png"`, `end expect.toHaveScreenshot(is-a-test-1.png)`, `end fixture: page`, `end fixture: context`, @@ -681,6 +677,30 @@ test('should write missing expectations locally twice and attach them', async ({ ]); }); +test('should attach missing expectations to right step', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + onStepEnd(test, result, step) { + if (step.attachments.length > 0) + console.log(\`%%\${step.title}: \${step.attachments.map(a => a.name).join(", ")}\`); + } + } + module.exports = Reporter; + `, + ...playwrightConfig({ reporter: [['dot'], ['./reporter']] }), + 'a.spec.js': ` + const { test, expect } = require('@playwright/test'); + test('is a test', async ({ page }) => { + await expect(page).toHaveScreenshot('snapshot.png'); + }); + `, + }, { reporter: '' }); + + expect(result.exitCode).toBe(1); + expect(result.outputLines).toEqual(['expect.toHaveScreenshot(snapshot.png): snapshot-expected.png, snapshot-actual.png']); +}); + test('shouldn\'t write missing expectations locally for negated matcher', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ ...playwrightConfig({ diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 06cff62399..23a321338b 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -94,8 +94,6 @@ test('should merge screenshot assertions', async ({ runUITest }, testInfo) => { /Before Hooks[\d.]+m?s/, /page.setContent[\d.]+m?s/, /expect.toHaveScreenshot[\d.]+m?s/, - /attach "trace-test-1-expected.png/, - /attach "trace-test-1-actual.png/, /After Hooks[\d.]+m?s/, /Worker Cleanup[\d.]+m?s/, ]); @@ -425,3 +423,50 @@ test('should show custom fixture titles in actions tree', async ({ runUITest }) /After Hooks[\d.]+m?s/, ]); }); + +test('attachments tab shows all but top-level .push attachments', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('attachment test', async ({}) => { + await test.step('step', async () => { + test.info().attachments.push({ + name: 'foo-push', + body: Buffer.from('foo-content'), + contentType: 'text/plain' + }); + + await test.info().attach('foo-attach', { body: 'foo-content' }) + }); + + test.info().attachments.push({ + name: 'bar-push', + body: Buffer.from('bar-content'), + contentType: 'text/plain' + }); + await test.info().attach('bar-attach', { body: 'bar-content' }) + }); + `, + }); + + await page.getByRole('treeitem', { name: 'attachment test' }).dblclick(); + const actionsTree = page.getByTestId('actions-tree'); + await actionsTree.getByRole('treeitem', { name: 'step' }).click(); + await page.keyboard.press('ArrowRight'); + await expect(actionsTree, 'attach() and top-level attachments.push calls are shown as actions').toMatchAriaSnapshot(` + - tree: + - treeitem /step/: + - group: + - treeitem /attach \\"foo-attach\\"/ + - treeitem /attach \\"bar-push\\"/ + - treeitem /attach \\"bar-attach\\"/ + `); + await page.getByRole('tab', { name: 'Attachments' }).click(); + await expect(page.getByRole('tabpanel', { name: 'Attachments' })).toMatchAriaSnapshot(` + - tabpanel: + - button /foo-push/ + - button /foo-attach/ + - button /bar-push/ + - button /bar-attach/ + `); +}); From 6bdd2694ee6b0d34bfc8fb4151223c6f3e31a9e5 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 3 Jan 2025 11:34:34 +0100 Subject: [PATCH 69/73] feat(webserver): customize shutdown with new `gracefulShutdown` option (#34130) Signed-off-by: Simon Knott Co-authored-by: Dmitry Gozman --- docs/src/test-api/class-testconfig.md | 3 + docs/src/test-webserver-js.md | 1 + .../src/utils/processLauncher.ts | 2 +- .../playwright/src/plugins/webServerPlugin.ts | 30 +++++++-- packages/playwright/types/test.d.ts | 12 ++++ tests/playwright-test/web-server.spec.ts | 64 +++++++++++++++++++ 6 files changed, 106 insertions(+), 6 deletions(-) diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 37b9ca5f27..90425fcf4c 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -629,6 +629,9 @@ export default defineConfig({ - `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. - `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. - `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. + - `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. + - `signal` <["SIGINT"|"SIGTERM"]> + - `timeout` <[int]> - `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified. Launch a development web server (or multiple) during the tests. diff --git a/docs/src/test-webserver-js.md b/docs/src/test-webserver-js.md index bf01a5cd27..f4c86197c0 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -37,6 +37,7 @@ export default defineConfig({ | `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. | | `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. | | `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. | +| `gracefulShutdown` | How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. | ## Adding a server timeout diff --git a/packages/playwright-core/src/utils/processLauncher.ts b/packages/playwright-core/src/utils/processLauncher.ts index 4e6c1030b2..1310f95277 100644 --- a/packages/playwright-core/src/utils/processLauncher.ts +++ b/packages/playwright-core/src/utils/processLauncher.ts @@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise {}; const waitForCleanup = new Promise(f => fulfillCleanup = f); - spawnedProcess.once('exit', (exitCode, signal) => { + spawnedProcess.once('close', (exitCode, signal) => { options.log(`[pid=${spawnedProcess.pid}] `); processClosed = true; gracefullyCloseSet.delete(gracefullyClose); diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index e2474f35f2..002ad235bd 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -30,6 +30,7 @@ export type WebServerPluginOptions = { url?: string; ignoreHTTPSErrors?: boolean; timeout?: number; + gracefulShutdown?: { signal: 'SIGINT' | 'SIGTERM', timeout?: number }; reuseExistingServer?: boolean; cwd?: string; env?: { [key: string]: string; }; @@ -92,7 +93,7 @@ export class WebServerPlugin implements TestRunnerPlugin { } debugWebServer(`Starting WebServer process ${this._options.command}...`); - const { launchedProcess, kill } = await launchProcess({ + const { launchedProcess, gracefullyClose } = await launchProcess({ command: this._options.command, env: { ...DEFAULT_ENVIRONMENT_VARIABLES, @@ -102,14 +103,33 @@ export class WebServerPlugin implements TestRunnerPlugin { cwd: this._options.cwd, stdio: 'stdin', shell: true, - // Reject to indicate that we cannot close the web server gracefully - // and should fallback to non-graceful shutdown. - attemptToGracefullyClose: () => Promise.reject(), + attemptToGracefullyClose: async () => { + if (process.platform === 'win32') + throw new Error('Graceful shutdown is not supported on Windows'); + if (!this._options.gracefulShutdown) + throw new Error('skip graceful shutdown'); + + const { signal, timeout = 0 } = this._options.gracefulShutdown; + + // proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html + // there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency. + process.kill(-launchedProcess.pid!, signal); + + return new Promise((resolve, reject) => { + const timer = timeout !== 0 + ? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout`)), timeout) + : undefined; + launchedProcess.once('close', (...args) => { + clearTimeout(timer); + resolve(); + }); + }); + }, log: () => {}, onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')), tempDirectories: [], }); - this._killProcess = kill; + this._killProcess = gracefullyClose; debugWebServer(`Process started`); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 7c9cae5415..cff8c8ca60 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -9657,6 +9657,18 @@ interface TestConfigWebServer { */ timeout?: number; + /** + * How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: + * 'SIGINT', timeout: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit + * within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't + * support `SIGINT` and `SIGTERM` signals, so this option is ignored. + */ + gracefulShutdown?: { + signal: "SIGINT"|"SIGTERM"; + + timeout: number; + }; + /** * The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the * server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is diff --git a/tests/playwright-test/web-server.spec.ts b/tests/playwright-test/web-server.spec.ts index 04e3c1d328..6caed12a42 100644 --- a/tests/playwright-test/web-server.spec.ts +++ b/tests/playwright-test/web-server.spec.ts @@ -17,6 +17,7 @@ import type http from 'http'; import path from 'path'; import { test, expect, parseTestRunnerOutput } from './playwright-test-fixtures'; +import type { RunResult } from './playwright-test-fixtures'; import { createHttpServer } from '../../packages/playwright-core/lib/utils/network'; const SIMPLE_SERVER_PATH = path.join(__dirname, 'assets', 'simple-server.js'); @@ -744,3 +745,66 @@ test('should forward stdout when set to "pipe" before server is ready', async ({ expect(result.output).toContain('[WebServer] output from server'); expect(result.output).not.toContain('Timed out waiting 3000ms'); }); + +test.describe('gracefulShutdown option', () => { + test.skip(process.platform === 'win32', 'No sending SIGINT on Windows'); + + const files = (additionalOptions = {}) => { + const port = test.info().workerIndex * 2 + 10510; + return { + 'child.js': ` + process.on('SIGINT', () => { console.log('%%childprocess received SIGINT'); setTimeout(() => process.exit(), 10) }) + process.on('SIGTERM', () => { console.log('%%childprocess received SIGTERM'); setTimeout(() => process.exit(), 10) }) + setTimeout(() => {}, 100000) // prevent child from exiting + `, + 'web-server.js': ` + require("node:child_process").fork('./child.js', { silent: false }) + + process.on('SIGINT', () => { + console.log('%%webserver received SIGINT but stubbornly refuses to wind down') + }) + process.on('SIGTERM', () => { + console.log('%%webserver received SIGTERM but stubbornly refuses to wind down') + }) + + const server = require("node:http").createServer((req, res) => { res.end("ok"); }) + server.listen(process.argv[2]); + `, + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}) => {}); + `, + 'playwright.config.ts': ` + module.exports = { + webServer: { + command: 'echo some-precondition && node web-server.js ${port}', + port: ${port}, + stdout: 'pipe', + timeout: 3000, + ...${JSON.stringify(additionalOptions)} + }, + }; + `, + }; + }; + + function parseOutputLines(result: RunResult): string[] { + const prefix = '[WebServer] %%'; + return result.output.split('\n').filter(line => line.startsWith(prefix)).map(line => line.substring(prefix.length)); + } + + test('sends SIGKILL by default', async ({ runInlineTest }) => { + const result = await runInlineTest(files(), { workers: 1 }); + expect(parseOutputLines(result)).toEqual([]); + }); + + test('can be configured to send SIGTERM', async ({ runInlineTest }) => { + const result = await runInlineTest(files({ gracefulShutdown: { signal: 'SIGTERM', timeout: 500 } }), { workers: 1 }); + expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGTERM', 'webserver received SIGTERM but stubbornly refuses to wind down']); + }); + + test('can be configured to send SIGINT', async ({ runInlineTest }) => { + const result = await runInlineTest(files({ gracefulShutdown: { signal: 'SIGINT', timeout: 500 } }), { workers: 1 }); + expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGINT', 'webserver received SIGINT but stubbornly refuses to wind down']); + }); +}); From dca95ba609a445b71f84501f8ee487c1b114e219 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 3 Jan 2025 10:39:32 -0800 Subject: [PATCH 70/73] fix(bidi): set initial frame url from creation event (#34198) --- packages/playwright-core/src/server/bidi/bidiBrowser.ts | 4 ++++ tests/bidi/expectations/bidi-chromium-library.txt | 2 +- tests/bidi/expectations/bidi-firefox-nightly-library.txt | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 955f6274a3..96c48ea2a8 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -152,6 +152,9 @@ export class BidiBrowser extends Browser { continue; page._session.addFrameBrowsingContext(event.context); page._page._frameManager.frameAttached(event.context, parentFrameId); + const frame = page._page._frameManager.frame(event.context); + if (frame) + frame._url = event.url; return; } return; @@ -164,6 +167,7 @@ export class BidiBrowser extends Browser { const session = this._connection.createMainFrameBrowsingContextSession(event.context); const opener = event.originalOpener && this._bidiPages.get(event.originalOpener); const page = new BidiPage(context, session, opener || null); + page._page.mainFrame()._url = event.url; this._bidiPages.set(event.context, page); } diff --git a/tests/bidi/expectations/bidi-chromium-library.txt b/tests/bidi/expectations/bidi-chromium-library.txt index 13dbf66eb5..ecdb4e425c 100644 --- a/tests/bidi/expectations/bidi-chromium-library.txt +++ b/tests/bidi/expectations/bidi-chromium-library.txt @@ -310,7 +310,7 @@ library/browsercontext-network-event.spec.ts › BrowserContext.Events.RequestFi library/browsercontext-network-event.spec.ts › BrowserContext.Events.Response [pass] library/browsercontext-network-event.spec.ts › should fire events in proper order [pass] library/browsercontext-network-event.spec.ts › should not fire events for favicon or favicon redirects [unknown] -library/browsercontext-page-event.spec.ts › should fire page lifecycle events [fail] +library/browsercontext-page-event.spec.ts › should fire page lifecycle events [pass] library/browsercontext-page-event.spec.ts › should have about:blank for empty url with domcontentloaded [fail] library/browsercontext-page-event.spec.ts › should have about:blank url with domcontentloaded [fail] library/browsercontext-page-event.spec.ts › should have an opener [pass] diff --git a/tests/bidi/expectations/bidi-firefox-nightly-library.txt b/tests/bidi/expectations/bidi-firefox-nightly-library.txt index fd0ce6cea6..f0920dbee4 100644 --- a/tests/bidi/expectations/bidi-firefox-nightly-library.txt +++ b/tests/bidi/expectations/bidi-firefox-nightly-library.txt @@ -312,7 +312,7 @@ library/browsercontext-network-event.spec.ts › BrowserContext.Events.Response library/browsercontext-network-event.spec.ts › should fire events in proper order [pass] library/browsercontext-network-event.spec.ts › should not fire events for favicon or favicon redirects [unknown] library/browsercontext-network-event.spec.ts › should reject response.finished if context closes [timeout] -library/browsercontext-page-event.spec.ts › should fire page lifecycle events [fail] +library/browsercontext-page-event.spec.ts › should fire page lifecycle events [pass] library/browsercontext-page-event.spec.ts › should have about:blank for empty url with domcontentloaded [timeout] library/browsercontext-page-event.spec.ts › should have about:blank url with domcontentloaded [pass] library/browsercontext-page-event.spec.ts › should have an opener [pass] From 8b45ea6f2f1c176d63909a2c4199008177f687ae Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 3 Jan 2025 12:16:01 -0800 Subject: [PATCH 71/73] chore: properly initialize Touch arguments in TouchEvent (#34200) --- .../src/server/injected/injectedScript.ts | 13 +++- .../locator-dispatchevent-touch.spec.ts | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/library/locator-dispatchevent-touch.spec.ts diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 21fcca69a4..c3d6e296e5 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -996,13 +996,20 @@ export class InjectedScript { return { stop }; } - dispatchEvent(node: Node, type: string, eventInit: Object) { + dispatchEvent(node: Node, type: string, eventInitObj: Object) { let event; - eventInit = { bubbles: true, cancelable: true, composed: true, ...eventInit }; + const eventInit: any = { bubbles: true, cancelable: true, composed: true, ...eventInitObj }; switch (eventType.get(type)) { case 'mouse': event = new MouseEvent(type, eventInit); break; case 'keyboard': event = new KeyboardEvent(type, eventInit); break; - case 'touch': event = new TouchEvent(type, eventInit); break; + case 'touch': { + eventInit.target ??= node; + eventInit.touches = eventInit.touches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node })); + eventInit.targetTouches = eventInit.targetTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node })); + eventInit.changedTouches = eventInit.changedTouches?.map((t: any) => t instanceof Touch ? t : new Touch({ ...t, target: t.target ?? node })); + event = new TouchEvent(type, eventInit); + break; + } case 'pointer': event = new PointerEvent(type, eventInit); break; case 'focus': event = new FocusEvent(type, eventInit); break; case 'drag': event = new DragEvent(type, eventInit); break; diff --git a/tests/library/locator-dispatchevent-touch.spec.ts b/tests/library/locator-dispatchevent-touch.spec.ts new file mode 100644 index 0000000000..c811c847f0 --- /dev/null +++ b/tests/library/locator-dispatchevent-touch.spec.ts @@ -0,0 +1,59 @@ +/** + * 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.use({ hasTouch: true }); + +it('should support touch points in touch event arguments', async ({ page, server, browserName }) => { + it.fixme(browserName === 'webkit', 'WebKit does not have Touch constructor'); + await page.goto(server.EMPTY_PAGE); + await page.setContent(` +
+
inner
+
`); + const outer = page.getByTestId('outer'); + await outer.evaluate(el => { + const events = []; + (window as any).events = events; + el.addEventListener('touchstart', (e: TouchEvent) => events.push('touchstart: ' + [...e.touches].map(t => `${t.constructor.name}(id: ${t.identifier}, clientX: ${t.clientX}, clientY: ${t.clientY})`))); + el.addEventListener('touchmove', (e: TouchEvent) => events.push('touchmove: ' + [...e.touches].map(t => `${t.constructor.name}(id: ${t.identifier}, clientX: ${t.clientX}, clientY: ${t.clientY})`))); + el.addEventListener('touchend', (e: TouchEvent) => events.push('touchend: ' + [...e.touches].map(t => `${t.constructor.name}(id: ${t.identifier}, clientX: ${t.clientX}, clientY: ${t.clientY})`))); + }); + + const touches = [{ identifier: 0, clientX: 61, clientY: 60 }, { identifier: 1, clientX: 59, clientY: 60 }]; + const inner = page.getByTestId('inner'); + await inner.dispatchEvent('touchstart', { + touches, + changedTouches: touches, + targetTouches: touches, + }); + await inner.dispatchEvent('touchmove', { + touches, + changedTouches: touches, + targetTouches: touches, + }); + await inner.dispatchEvent('touchend', { + touches: [], + changedTouches: touches, + targetTouches: [], + }); + expect(await page.evaluate(() => (window as any).events)).toEqual([ + 'touchstart: Touch(id: 0, clientX: 61, clientY: 60),Touch(id: 1, clientX: 59, clientY: 60)', + 'touchmove: Touch(id: 0, clientX: 61, clientY: 60),Touch(id: 1, clientX: 59, clientY: 60)', + 'touchend: ', + ]); +}); From 5a22475ea81380bbb432fe6a135e0047ff36ad25 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 3 Jan 2025 12:37:28 -0800 Subject: [PATCH 72/73] chore(bidi): fix signals tests (#34209) --- packages/playwright-core/src/browserServerImpl.ts | 4 ++-- packages/playwright-core/src/inProcessFactory.ts | 2 ++ tests/bidi/expectations/bidi-chromium-library.txt | 10 ---------- .../bidi/expectations/bidi-firefox-nightly-library.txt | 10 ---------- tests/config/browserTest.ts | 4 ++-- tests/config/remoteServer.ts | 9 ++++++++- 6 files changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index dfe960c5ea..d59c95bfb1 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -29,9 +29,9 @@ import { rewriteErrorMessage } from './utils/stackTrace'; import { SocksProxy } from './common/socksProxy'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { - private _browserName: 'chromium' | 'firefox' | 'webkit'; + private _browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium'; - constructor(browserName: 'chromium' | 'firefox' | 'webkit') { + constructor(browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium') { this._browserName = browserName; } diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 1397c81958..a757294da8 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -41,6 +41,8 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); + playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('bidiChromium'); + playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('bidiFirefox'); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/tests/bidi/expectations/bidi-chromium-library.txt b/tests/bidi/expectations/bidi-chromium-library.txt index ecdb4e425c..e49e4cedb2 100644 --- a/tests/bidi/expectations/bidi-chromium-library.txt +++ b/tests/bidi/expectations/bidi-chromium-library.txt @@ -1674,16 +1674,6 @@ library/selectors-register.spec.ts › should work in main and isolated world [p library/selectors-register.spec.ts › should work when registered on global [pass] library/selectors-register.spec.ts › should work with path [pass] library/shared-worker.spec.ts › should survive shared worker restart [timeout] -library/signals.spec.ts › should close the browser when the node process closes [timeout] -library/signals.spec.ts › should remove temp dir on process.exit [timeout] -library/signals.spec.ts › signals › should close the browser on SIGHUP [timeout] -library/signals.spec.ts › signals › should close the browser on SIGINT [timeout] -library/signals.spec.ts › signals › should close the browser on SIGTERM [timeout] -library/signals.spec.ts › signals › should kill the browser on SIGINT + SIGTERM [timeout] -library/signals.spec.ts › signals › should kill the browser on SIGTERM + SIGINT [timeout] -library/signals.spec.ts › signals › should kill the browser on double SIGINT and remove temp dir [timeout] -library/signals.spec.ts › signals › should not prevent default SIGTERM handling after browser close [timeout] -library/signals.spec.ts › signals › should report browser close signal 2 [timeout] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo check [pass] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo click [pass] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo dblclick [pass] diff --git a/tests/bidi/expectations/bidi-firefox-nightly-library.txt b/tests/bidi/expectations/bidi-firefox-nightly-library.txt index f0920dbee4..c4d8b933fa 100644 --- a/tests/bidi/expectations/bidi-firefox-nightly-library.txt +++ b/tests/bidi/expectations/bidi-firefox-nightly-library.txt @@ -1726,16 +1726,6 @@ library/selectors-register.spec.ts › should work in main and isolated world [p library/selectors-register.spec.ts › should work when registered on global [pass] library/selectors-register.spec.ts › should work with path [pass] library/shared-worker.spec.ts › should survive shared worker restart [pass] -library/signals.spec.ts › should close the browser when the node process closes [timeout] -library/signals.spec.ts › should remove temp dir on process.exit [timeout] -library/signals.spec.ts › signals › should close the browser on SIGHUP [timeout] -library/signals.spec.ts › signals › should close the browser on SIGINT [timeout] -library/signals.spec.ts › signals › should close the browser on SIGTERM [timeout] -library/signals.spec.ts › signals › should kill the browser on SIGINT + SIGTERM [timeout] -library/signals.spec.ts › signals › should kill the browser on SIGTERM + SIGINT [timeout] -library/signals.spec.ts › signals › should kill the browser on double SIGINT and remove temp dir [timeout] -library/signals.spec.ts › signals › should not prevent default SIGTERM handling after browser close [timeout] -library/signals.spec.ts › signals › should report browser close signal 2 [timeout] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo check [pass] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo click [pass] library/slowmo.spec.ts › slowMo › ElementHandle SlowMo dblclick [pass] diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 7836b2e38c..9eba4f30d1 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -137,14 +137,14 @@ const test = baseTest.extend await persistentContext.close(); }, - startRemoteServer: async ({ childProcess, browserType }, run) => { + startRemoteServer: async ({ childProcess, browserType, channel }, run) => { let server: PlaywrightServer | undefined; const fn = async (kind: 'launchServer' | 'run-server', options?: RemoteServerOptions) => { if (server) throw new Error('can only start one remote server'); if (kind === 'launchServer') { const remoteServer = new RemoteServer(); - await remoteServer._start(childProcess, browserType, options); + await remoteServer._start(childProcess, browserType, channel, options); server = remoteServer; } else { const runServer = new RunServer(); diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index 6d9710fd44..94c476ed80 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -80,7 +80,7 @@ export class RemoteServer implements PlaywrightServer { _browser: Browser | undefined; _wsEndpoint!: string; - async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, remoteServerOptions: RemoteServerOptions = {}) { + async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, channel: string, remoteServerOptions: RemoteServerOptions = {}) { this._browserType = browserType; const browserOptions = (browserType as any)._defaultLaunchOptions; // Copy options to prevent a large JSON string when launching subprocess. @@ -97,9 +97,16 @@ export class RemoteServer implements PlaywrightServer { }; const options = { browserTypeName: browserType.name(), + channel, launchOptions, ...remoteServerOptions, }; + if ('bidi' === browserType.name()) { + if (channel.toLocaleLowerCase().includes('firefox')) + options.browserTypeName = '_bidiFirefox'; + else + options.browserTypeName = '_bidiChromium'; + } this._process = childProcess({ command: ['node', path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)], env: { ...process.env, PWTEST_UNDER_TEST: '1' }, From eeca68ba9714fef056fd60203240126607337a26 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Sun, 5 Jan 2025 18:19:28 +0000 Subject: [PATCH 73/73] test: unflake some cookie tests in msedge (#34217) --- tests/library/defaultbrowsercontext-1.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/library/defaultbrowsercontext-1.spec.ts b/tests/library/defaultbrowsercontext-1.spec.ts index 3aa7357d2f..1516be3487 100644 --- a/tests/library/defaultbrowsercontext-1.spec.ts +++ b/tests/library/defaultbrowsercontext-1.spec.ts @@ -19,7 +19,13 @@ import { playwrightTest as it, expect } from '../config/browserTest'; import { verifyViewport } from '../config/utils'; import fs from 'fs'; -it('context.cookies() should work @smoke', async ({ server, launchPersistent, defaultSameSiteCookieValue }) => { +function maybeFilterCookies(channel: string | undefined, cookies: any[]) { + if (channel?.startsWith('msedge')) + return cookies.filter(c => !c.domain.endsWith('microsoft.com')); + return cookies; +} + +it('context.cookies() should work @smoke', async ({ server, launchPersistent, defaultSameSiteCookieValue, channel }) => { const { page } = await launchPersistent(); await page.goto(server.EMPTY_PAGE); const documentCookie = await page.evaluate(() => { @@ -27,7 +33,7 @@ it('context.cookies() should work @smoke', async ({ server, launchPersistent, de return document.cookie; }); expect(documentCookie).toBe('username=John Doe'); - expect(await page.context().cookies()).toEqual([{ + expect(maybeFilterCookies(channel, await page.context().cookies())).toEqual([{ name: 'username', value: 'John Doe', domain: 'localhost', @@ -39,7 +45,7 @@ it('context.cookies() should work @smoke', async ({ server, launchPersistent, de }]); }); -it('context.addCookies() should work', async ({ server, launchPersistent, browserName, isWindows }) => { +it('context.addCookies() should work', async ({ server, launchPersistent, browserName, isWindows, channel }) => { const { page } = await launchPersistent(); await page.goto(server.EMPTY_PAGE); await page.context().addCookies([{ @@ -49,7 +55,7 @@ it('context.addCookies() should work', async ({ server, launchPersistent, browse sameSite: 'Lax', }]); expect(await page.evaluate(() => document.cookie)).toBe('username=John Doe'); - expect(await page.context().cookies()).toEqual([{ + expect(maybeFilterCookies(channel, await page.context().cookies())).toEqual([{ name: 'username', value: 'John Doe', domain: 'localhost', @@ -61,7 +67,7 @@ it('context.addCookies() should work', async ({ server, launchPersistent, browse }]); }); -it('context.clearCookies() should work', async ({ server, launchPersistent }) => { +it('context.clearCookies() should work', async ({ server, launchPersistent, channel }) => { const { page } = await launchPersistent(); await page.goto(server.EMPTY_PAGE); await page.context().addCookies([{ @@ -76,7 +82,7 @@ it('context.clearCookies() should work', async ({ server, launchPersistent }) => expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); await page.context().clearCookies(); await page.reload(); - expect(await page.context().cookies([])).toEqual([]); + expect(maybeFilterCookies(channel, await page.context().cookies([]))).toEqual([]); expect(await page.evaluate('document.cookie')).toBe(''); });