diff --git a/.github/actions/run-test/action.yml b/.github/actions/run-test/action.yml index 624a3c2a29c93..bc985f15efff0 100644 --- a/.github/actions/run-test/action.yml +++ b/.github/actions/run-test/action.yml @@ -99,3 +99,8 @@ runs: with: report_dir: blob-report job_name: ${{ inputs.bot-name }}-${{ inputs.shard-index }} + - name: Upload parquet report + if: ${{ !cancelled() && env.PLAYWRIGHT_SETUP_COMPLETE == 'true' }} + uses: ./.github/actions/upload-parquet-report + with: + job_name: ${{ inputs.bot-name }}-${{ inputs.shard-index }} diff --git a/.github/actions/upload-parquet-report/action.yml b/.github/actions/upload-parquet-report/action.yml new file mode 100644 index 0000000000000..af8ae5567a995 --- /dev/null +++ b/.github/actions/upload-parquet-report/action.yml @@ -0,0 +1,21 @@ +name: 'Upload parquet report' +description: 'Upload the parquet test-results report to GitHub artifacts' +inputs: + report_file: + description: 'Path to the parquet report file' + required: false + default: 'test-results/test-results.parquet' + job_name: + description: 'Unique job name' + required: true +runs: + using: "composite" + steps: + - name: Upload parquet report to GitHub + if: ${{ hashFiles(inputs.report_file) != '' }} + uses: actions/upload-artifact@v7 + with: + name: parquet-report-${{ inputs.job_name }} + path: ${{ inputs.report_file }} + retention-days: 7 + overwrite: true diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index acd796fb03d4b..1bda2bacf9f6f 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -76,6 +76,12 @@ jobs: path: test-results/report.json retention-days: 7 + - name: Upload parquet report + if: ${{ !cancelled() }} + uses: ./.github/actions/upload-parquet-report + with: + job_name: ${{ matrix.channel }} + - name: Azure Login if: ${{ !cancelled() && github.ref == 'refs/heads/main' }} uses: azure/login@v3 diff --git a/.github/workflows/tests_docker.yml b/.github/workflows/tests_docker.yml index 8bae5f8e86298..2fd7dc34cb7a8 100644 --- a/.github/workflows/tests_docker.yml +++ b/.github/workflows/tests_docker.yml @@ -100,7 +100,17 @@ jobs: run: docker exec --workdir /home/pwuser/playwright docker-tests npm run build - name: "Run @smoke tests inside docker" - run: docker exec --workdir /home/pwuser/playwright docker-tests xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test -- --grep "@smoke" + run: | + docker exec \ + --env GITHUB_RUN_ID \ + --env GITHUB_RUN_ATTEMPT \ + --env GITHUB_WORKFLOW \ + --env GITHUB_EVENT_NAME \ + --env GITHUB_REF \ + --env GITHUB_REF_NAME \ + --env GITHUB_HEAD_REF \ + --env GITHUB_SHA \ + --workdir /home/pwuser/playwright docker-tests xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" npm run test -- --grep "@smoke" - name: Azure Login if: ${{ !cancelled() && github.event_name == 'push' && github.repository == 'microsoft/playwright' }} @@ -135,3 +145,14 @@ jobs: with: report_dir: blob-report job_name: docker-${{ matrix.docker_tag }}-${{ matrix.docker_arch }} + + - name: Copy parquet report from container + if: ${{ !cancelled() }} + run: docker cp "docker-tests:/home/pwuser/playwright/test-results/test-results.parquet" "./test-results.parquet" || true + + - name: Upload parquet report + if: ${{ !cancelled() }} + uses: ./.github/actions/upload-parquet-report + with: + report_file: test-results.parquet + job_name: docker-${{ matrix.docker_tag }}-${{ matrix.docker_arch }} diff --git a/.github/workflows/tests_extension.yml b/.github/workflows/tests_extension.yml index c55d28e3dc1fa..38a413f319c95 100644 --- a/.github/workflows/tests_extension.yml +++ b/.github/workflows/tests_extension.yml @@ -49,3 +49,8 @@ jobs: - run: npm run build - run: npx playwright install --with-deps chromium - run: npm run test-extension + - name: Upload parquet report + if: ${{ !cancelled() }} + uses: ./.github/actions/upload-parquet-report + with: + job_name: extension-${{ matrix.os }} diff --git a/.github/workflows/tests_webview_simulator.yml b/.github/workflows/tests_webview_simulator.yml index 3f484c8ce67bb..0a8c1cea2ba9e 100644 --- a/.github/workflows/tests_webview_simulator.yml +++ b/.github/workflows/tests_webview_simulator.yml @@ -158,3 +158,9 @@ jobs: path: | ${{ github.workspace }}/test-results/** if-no-files-found: ignore + + - name: Upload parquet report + if: ${{ !cancelled() }} + uses: ./.github/actions/upload-parquet-report + with: + job_name: webview-simulator-${{ matrix.shard }} diff --git a/package-lock.json b/package-lock.json index b08b5f6ade1cb..035d7711e3584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@babel/plugin-transform-private-property-in-object": "7.29.7", "@babel/plugin-transform-react-jsx": "7.29.7", "@babel/preset-typescript": "7.29.7", + "@duckdb/node-api": "1.5.4-r.1", "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", @@ -976,6 +977,148 @@ "resolved": "https://registry.npmjs.org/@browser-logos/safari/-/safari-2.1.0.tgz", "integrity": "sha512-diidPiK62E4hlAh0dyLfWQDZXi2SSAGiOuw6iqD1x8ztw7L/Sz3He46FhcxEzYa1hKi1blCkjnKDjqw6rQfgcA==" }, + "node_modules/@duckdb/node-api": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.4-r.1.tgz", + "integrity": "sha512-3PYk/4//svuYmb9RYVM71cCoNa6ngoYWwrMhJaqbm010txzIMWbJCbxrFeqDl+krdIug+Hc/MNCeS0gHsTXSIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@duckdb/node-bindings": "1.5.4-r.1" + } + }, + "node_modules/@duckdb/node-bindings": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.4-r.1.tgz", + "integrity": "sha512-v834OZZKQ59yAvh8GOEmM+R1foPNgdMGL0VTBgHywgblgQNyq11HvWgT4QGJIUFfo/cQ9WFyf1MFhK0+hV1aiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "optionalDependencies": { + "@duckdb/node-bindings-darwin-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-darwin-x64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-arm64-musl": "1.5.4-r.1", + "@duckdb/node-bindings-linux-x64": "1.5.4-r.1", + "@duckdb/node-bindings-linux-x64-musl": "1.5.4-r.1", + "@duckdb/node-bindings-win32-arm64": "1.5.4-r.1", + "@duckdb/node-bindings-win32-x64": "1.5.4-r.1" + } + }, + "node_modules/@duckdb/node-bindings-darwin-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-fl8EC5xdmI7VAI7bX4W6D7lOGNtxyLvSxGjOY8Ov7viVEKwIcBXXKlMPgh3sw+PiVlwZhKlknuCI8BL5xXpSDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-darwin-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.4-r.1.tgz", + "integrity": "sha512-E8bECZ6abJqunPqVR1NA0Zho7qxanfDWWFuJrKMsU1NCUqyGLyhXqZwsRHAx7J6jrM+lhQ7wOZj1x/N4OVml3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-4EsB+cgRqOE++mdPQ2ZoGJCg4ylmuqdbLDtmo3L19B+C5OzGmrhxlKD4o75eGEtfO52M+w+TdL0qhy0H5XvaKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64-musl": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.4-r.1.tgz", + "integrity": "sha512-96Pyk5Syy18S5DSN0CKkOQ7/CsyVGRML8ewox1qpFDjYvPH1VsULlQoIRwbpw7mkFEy17wuWmbwzb7oKu/nGMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.4-r.1.tgz", + "integrity": "sha512-IldapRydno5kf4VkPCe8yK+j4pCg+j6Qs46UmLnKex/mtIdJa7ifhMYRkvdc5KIAFEC0eEL5c830QeuwcUROyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64-musl": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.4-r.1.tgz", + "integrity": "sha512-tWgnttlKfWTktyv+m9YbxaedKBon5wmUoJ9DaIbE3D98KhhUaqmJhnjzso9O8hp6WUeRmwXR//hpT/X6Ft9nYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-win32-arm64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.4-r.1.tgz", + "integrity": "sha512-77apmuNo51bPZzv8xjdeCp+hlU6JLwMPtS1CMViy83ONTsah+CGnpBx1lCnEqPL5Va0meufZyjSdZCVCijLdDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@duckdb/node-bindings-win32-x64": { + "version": "1.5.4-r.1", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.4-r.1.tgz", + "integrity": "sha512-Wme7dScBGqjn2PUJ+RLFiFAblW1qQRBraX7SJc4uKtftZiUJJWZapEUh+doGfb68Qxvw8JhlMBSdaUsSUDJYsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", diff --git a/package.json b/package.json index 78f7b8b6af487..cd2015d4faa7d 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@babel/plugin-transform-private-property-in-object": "7.29.7", "@babel/plugin-transform-react-jsx": "7.29.7", "@babel/preset-typescript": "7.29.7", + "@duckdb/node-api": "1.5.4-r.1", "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.4", diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 0ce7c80dd9c28..d4a4ccad07017 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -334,6 +334,12 @@ export class InjectedScript { return { ariaSnapshot, refs: tree.refs }; } + ariaSnapshotForExpectFailure(element: Element, options: AriaTreeOptions): string { + // Bypass _lastAriaSnapshotForQuery — that cache is reserved for explicit + // ariaSnapshot() calls used by the aria-ref selector engine. + return renderAriaTree(generateAriaTree(element, options), options).text; + } + getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] { return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template); } @@ -1437,85 +1443,44 @@ export class InjectedScript { this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners); } - async expect(element: Element | undefined, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived, missingReceived?: boolean }> { - const core = await this._expectCore(element, options, elements); + async expect(element: Element, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: ExpectReceived }> { + const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); + const core = isArray ? this.expectArray(elements, options) : await this.expectSingleElement(element, options); const ariaSnapshot = core.matches !== options.isNot ? undefined : this._ariaSnapshotForExpect(element, options); if (core.received === undefined && ariaSnapshot === undefined) - return { matches: core.matches, missingReceived: core.missingReceived }; - return { matches: core.matches, received: { value: core.received, ariaSnapshot }, missingReceived: core.missingReceived }; + return { matches: core.matches }; + return { matches: core.matches, received: { value: core.received, ariaSnapshot } }; } - private _ariaSnapshotForExpect(element: Element | undefined, options: FrameExpectParams): string | undefined { + private _ariaSnapshotForExpect(element: Element, options: FrameExpectParams): string | undefined { const expression = options.expression; - if (expression === 'to.have.count' || expression.endsWith('.array')) - return undefined; - if (expression === 'to.match.aria') + if (expression === 'to.have.count' || expression.endsWith('.array') || expression === 'to.match.aria') return undefined; - if (element && isElementVisible(element)) { + if (isElementVisible(element) && expression !== 'to.have.title' && expression !== 'to.have.url') { // Element-scoped snapshot. Containment matchers want the full subtree; // property matchers only need the element's own line. const isContainment = expression === 'to.have.text'; - return this._renderAriaSnapshot(element, { mode: 'default', depth: isContainment ? undefined : 1 }); + return this.ariaSnapshotForExpectFailure(element, { mode: 'default', depth: isContainment ? undefined : 1 }); } - // Element missing or hidden — fall back to a full-page snapshot for context. if (!this.document.body) return undefined; - return this._renderAriaSnapshot(this.document.body, { mode: 'default' }); + return this.ariaSnapshotForExpectFailure(this.document.body, { mode: 'default' }); } - private _renderAriaSnapshot(element: Element, options: AriaTreeOptions): string { - // Bypass _lastAriaSnapshotForQuery — that cache is reserved for explicit - // ariaSnapshot() calls used by the aria-ref selector engine. - return renderAriaTree(generateAriaTree(element, options), options).text; - } + private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> { + const expression = options.expression; - private async _expectCore(element: Element | undefined, options: FrameExpectParams, elements: Element[]): Promise<{ matches: boolean, received?: any, missingReceived?: boolean }> { - const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); - if (isArray) - return this.expectArray(elements, options); - if (!element) { - // expect(locator).toBeHidden() passes when there is no element. - if (!options.isNot && options.expression === 'to.be.hidden') - return { matches: true }; - // expect(locator).not.toBeVisible() passes when there is no element. - if (options.isNot && options.expression === 'to.be.visible') - return { matches: false }; - // expect(locator).toBeAttached({ attached: false }) passes when there is no element. - if (!options.isNot && options.expression === 'to.be.detached') - return { matches: true }; - // expect(locator).not.toBeAttached() passes when there is no element. - if (options.isNot && options.expression === 'to.be.attached') - return { matches: false }; - // expect(locator).not.toBeInViewport() passes when there is no element. - if (options.isNot && options.expression === 'to.be.in.viewport') - return { matches: false }; - if (options.expression === 'to.have.title' && options?.expectedText?.[0]) { - const matcher = new ExpectedTextMatcher(options.expectedText[0]); + { + // Page-level values. The element (:root) is only used to reach the document. + if (expression === 'to.have.title') { const received = this.document.title; - return { received, matches: matcher.matches(received) }; + return { received, matches: new ExpectedTextMatcher(options.expectedText![0]).matches(received) }; } - if (options.expression === 'to.have.url' && options?.expectedText?.[0]) { - const matcher = new ExpectedTextMatcher(options.expectedText[0]); + if (expression === 'to.have.url') { const received = this.document.location.href; - return { received, matches: matcher.matches(received) }; - } - if (options.expression === 'to.match.aria' && !options.selector) { - if (!this.document.body) - return { matches: options.isNot, missingReceived: true }; - const result = matchesExpectAriaTemplate(this.document.body, options.expectedValue); - return { - received: result.received, - matches: !!result.matches.length, - }; + return { received, matches: new ExpectedTextMatcher(options.expectedText![0]).matches(received) }; } - // When none of the above applies, expect does not match. - return { matches: options.isNot, missingReceived: true }; } - return await this.expectSingleElement(element, options); - } - - private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> { - const expression = options.expression; { // Element state / boolean values. diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index aec8cc1249ac8..908a16c24e26c 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -27,6 +27,7 @@ import { BidiNetworkManager } from './bidiNetworkManager'; import { BidiPDF } from './bidiPdf'; import * as bidi from './third_party/bidiProtocol'; import { nullProgress } from '../progress'; +import { startAutomaticVideoRecording } from '../videoRecorder'; import * as frames from '../frames'; import * as network from '../network'; @@ -56,6 +57,8 @@ export class BidiPage implements PageDelegate { private _initScriptIds = new Map(); private readonly _fragmentNavigations = new Set(); private readonly _failedNavigations = new Map(); + private _screencastTimer: NodeJS.Timeout | undefined; + private _waitingForScreenshot = false; constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { this._session = bidiSession; @@ -103,6 +106,7 @@ export class BidiPage implements PageDelegate { // If the page is created by the Playwright client's call, some initialization // may be pending. Wait for it to complete before reporting the page as new. ]); + startAutomaticVideoRecording(this._page); } didClose() { @@ -576,9 +580,53 @@ export class BidiPage implements PageDelegate { } startScreencast(options: { width: number, height: number, quality: number }) { + if (this._screencastTimer) + return; + + this._waitingForScreenshot = false; + this._screencastTimer = setInterval(async () => { + if (this._waitingForScreenshot) + return; + if (this._session.isDisposed()) { + this.stopScreencast(); + return; + } + + this._waitingForScreenshot = true; + const payload = await this._session.sendMayFail('browsingContext.captureScreenshot', { + context: this._session.sessionId, + format: { + type: 'image/jpeg', + quality: options.quality / 100 + } + }); + if (payload) { + const buffer = Buffer.from(payload.data, 'base64'); + const { width, height } = jpegDimensions(buffer); + this._page.screencast.onScreencastFrame({ + buffer, + frameSwapWallTime: Date.now(), + viewportWidth: width, + viewportHeight: height, + }); + } + this._waitingForScreenshot = false; + }, 40); } stopScreencast() { + if (this._screencastTimer) { + clearInterval(this._screencastTimer); + this._screencastTimer = undefined; + } + } + + getFFmpegVideoFilterArgs({ width, height }: { width: number, height: number }) { + // We use "scale" and "pad" video filters (-vf option) to resize incoming frames + // that might be of a different size to the desired video size. + // https://ffmpeg.org/ffmpeg-filters.html#scale + // https://ffmpeg.org/ffmpeg-filters.html#pad-1 + return `scale=w='min(iw,${width})':h='min(ih,${height})':force_original_aspect_ratio=decrease:eval=frame,pad=${width}:${height}:0:0:gray`; } rafCountForStablePosition(): number { @@ -679,3 +727,22 @@ export class BidiPage implements PageDelegate { function toBidiExecutionContext(executionContext: dom.FrameExecutionContext): BidiExecutionContext { return executionContext.delegate as BidiExecutionContext; } + +function jpegDimensions(buffer: Buffer): { width: number, height: number } { + let i = 2; // skip SOI marker (FF D8) + while (i < buffer.length - 8) { + if (buffer[i] !== 0xFF) + break; + const marker = buffer[i + 1]; + const segmentLength = buffer.readUInt16BE(i + 2); + // SOF markers: C0 (baseline), C2 (progressive), C1, C3, C5-C7, C9-CB, CD-CF + if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || + (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) { + const height = buffer.readUInt16BE(i + 5); + const width = buffer.readUInt16BE(i + 7); + return { width, height }; + } + i += 2 + segmentLength; + } + throw new Error('Could not parse JPEG dimensions'); +} diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 6a0c8a872877d..1cf7edeb70ac2 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -55,7 +55,6 @@ export class Dispatcher(); protected _disposed = false; protected _eventListeners: RegisteredListener[] = []; - readonly _activeProgressControllers = new Map(); readonly _guid: string; readonly _type: string; @@ -103,14 +102,8 @@ export class Dispatcher (this as any)[method](validParams, progress), validParams?.timeout); - } finally { - this._activeProgressControllers.delete(callMetadata.id); - } + createProgressController(callMetadata: CallMetadata): ProgressController { + return ProgressController.createForSdkObject(this._object, callMetadata); } _dispatchEvent>(method: T, params?: channels.EventsTraits[T]) { @@ -132,14 +125,14 @@ export class Dispatcher(); const collect = (dispatcher: DispatcherScope) => { - controllers.push(...dispatcher._activeProgressControllers.values()); + guids.add(dispatcher._guid); for (const child of [...dispatcher._dispatchers.values()]) collect(child); }; collect(this); - await Promise.all(controllers.map(controller => controller.abort(error))); + await this.connection.abortControllersForGuids(guids, error); } private _disposeRecursively(error: Error) { @@ -195,12 +188,22 @@ export class DispatcherConnection { readonly _dispatchersByBucket = new Map>(); onmessage = (message: object) => {}; private _waitOperations = new Map(); + private _activeProgressControllers = new Map(); private _isInProcess: boolean; constructor(isInProcess?: boolean) { this._isInProcess = !!isInProcess; } + async abortControllersForGuids(guids: Set, error: Error) { + const controllers: ProgressController[] = []; + for (const controller of this._activeProgressControllers.values()) { + if (controller.metadata.objectId && guids.has(controller.metadata.objectId)) + controllers.push(controller); + } + await Promise.all(controllers.map(controller => controller.abort(error))); + } + sendEvent(dispatcher: DispatcherScope, event: string, params: any) { const validator = findValidator(dispatcher._type, event, 'Event'); params = validator(params, '', this._validatorToWireContext()); @@ -309,7 +312,7 @@ export class DispatcherConnection { return; } if (method === '__abort__') { - await dispatcher?._activeProgressControllers.get(`call@${params.id}`)?.abort(new AbortError(undefined, { cause: params.reason })); + await this._activeProgressControllers.get(`call@${params.id}`)?.abort(new AbortError(undefined, { cause: params.reason })); return; } if (!dispatcher) { @@ -355,6 +358,8 @@ export class DispatcherConnection { params: params || {}, log: [], }; + const controller = dispatcher.createProgressController(callMetadata); + this._activeProgressControllers.set(callMetadata.id, controller); await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); const response: any = { id }; @@ -362,7 +367,7 @@ export class DispatcherConnection { // If the dispatcher has been disposed while running the instrumentation call, error out. if (this._dispatcherByGuid.get(guid) !== dispatcher) throw new TargetClosedError(sdkObject.closeReason()); - const result = await dispatcher._runCommand(callMetadata, method, validParams); + const result = await controller.run(progress => (dispatcher as any)[method](validParams, progress), validParams?.timeout); const validator = findValidator(dispatcher._type, method, 'Result'); response.result = validator(result, '', this._validatorToWireContext()); callMetadata.result = result; @@ -388,6 +393,7 @@ export class DispatcherConnection { await sdkObject.instrumentation.onAfterCall(sdkObject, callMetadata); if (metainfo?.slowMo) await this._doSlowMo(sdkObject); + this._activeProgressControllers.delete(callMetadata.id); } if (response.error) diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 6dfdb3dce2f29..e6b071d9713ed 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -20,7 +20,7 @@ import { asLocator } from '@isomorphic/locatorGenerators'; import type { ElementHandle, FrameExecutionContext } from './dom'; import type { Frame } from './frames'; import type { InjectedScript } from '@injected/injectedScript'; -import type { JSHandle } from './javascript'; +import type { JSHandle, SmartHandle } from './javascript'; import type * as types from './types'; import type { ParsedSelector } from '@isomorphic/selectorParser'; @@ -37,6 +37,8 @@ export type SelectorInFrame = { scope?: ElementHandle; }; +type MatchedElementsCallback = (data: { injected: InjectedScript, elements: Element[], info: SelectorInfo }, arg: Arg) => R | Promise; + export class FrameSelectors { readonly frame: Frame; @@ -50,72 +52,55 @@ export class FrameSelectors { } async query(selector: string, options?: types.StrictOptions & { mainWorld?: boolean }, scope?: ElementHandle): Promise | null> { - const resolved = await this.resolveInjectedForSelector(selector, options, scope); - // Be careful, |this.frame| can be different from |resolved.frame|. + const resolved = await this.callOnSelectorHandle(selector, { ...options, scope }, ({ elements }) => elements[0], {}); if (!resolved) return null; - const handle = await resolved.injected.evaluateHandle((injected, { info, scope }) => { - return injected.querySelector(info.parsed, scope || document, info.strict); - }, { info: resolved.info, scope: resolved.scope }); + const handle = resolved.result; const elementHandle = handle.asElement() as ElementHandle | null; if (!elementHandle) { handle.dispose(); return null; } - return adoptIfNeeded(elementHandle, await resolved.frame.mainContext()); + return adoptIfNeeded(elementHandle, await elementHandle._frame.mainContext()); } async queryArrayInMainWorld(selector: string, scope?: ElementHandle): Promise> { - const resolved = await this.resolveInjectedForSelector(selector, { mainWorld: true }, scope); - // Be careful, |this.frame| can be different from |resolved.frame|. + const resolved = await this.callOnSelectorHandle(selector, { mainWorld: true, strict: false, scope }, ({ elements }) => elements, {}); if (!resolved) { const context = await this.frame.context('main'); - return await context.evaluateHandle(() => { return []; }); + return await context.evaluateHandle(() => []); } - return await resolved.injected.evaluateHandle((injected, { info, scope }) => { - const elements = injected.querySelectorAll(info.parsed, scope || document); - injected.checkDeprecatedSelectorUsage(info.parsed, elements); - return elements; - }, { info: resolved.info, scope: resolved.scope }); + return resolved.result; } async queryCount(selector: string): Promise { - const resolved = await this.resolveInjectedForSelector(selector); - if (!resolved) - return 0; - return await resolved.injected.evaluate((injected, { info }) => { - const elements = injected.querySelectorAll(info.parsed, document); - injected.checkDeprecatedSelectorUsage(info.parsed, elements); - return elements.length; - }, { info: resolved.info }); + const resolved = await this.callOnSelector(selector, { strict: false }, ({ elements }) => elements.length, {}); + return resolved ? resolved.result : 0; } async queryAll(selector: string, scope?: ElementHandle): Promise[]> { - const resolved = await this.resolveInjectedForSelector(selector, {}, scope); - // Be careful, |this.frame| can be different from |resolved.frame|. + const resolved = await this.callOnSelectorHandle(selector, { strict: false, scope }, ({ elements }) => elements, {}); if (!resolved) return []; - const arrayHandle = await resolved.injected.evaluateHandle((injected, { info, scope }) => { - const elements = injected.querySelectorAll(info.parsed, scope || document); - injected.checkDeprecatedSelectorUsage(info.parsed, elements); - return elements; - }, { info: resolved.info, scope: resolved.scope }); + const arrayHandle = resolved.result; const properties = await arrayHandle.internalGetProperties(); - arrayHandle.dispose(); - - // Note: adopting elements one by one may be slow. If we encounter the issue here, - // we might introduce 'useMainContext' option or similar to speed things up. - const targetContext = await resolved.frame.mainContext(); - const result: Promise>[] = []; + const elementHandles: ElementHandle[] = []; for (const property of properties.values()) { - const elementHandle = property.asElement() as ElementHandle; + const elementHandle = property.asElement() as ElementHandle | null; if (elementHandle) - result.push(adoptIfNeeded(elementHandle, targetContext)); + elementHandles.push(elementHandle); else property.dispose(); } - return Promise.all(result); + arrayHandle.dispose(); + if (!elementHandles.length) + return []; + + // Note: adopting elements one by one may be slow. If we encounter the issue here, + // we might introduce 'useMainContext' option or similar to speed things up. + const targetContext = await elementHandles[0]._frame.mainContext(); + return Promise.all(elementHandles.map(handle => adoptIfNeeded(handle, targetContext))); } private _jumpToAriaRefFrameIfNeeded(selector: string, info: SelectorInfo, frame: Frame): Frame { @@ -132,7 +117,7 @@ export class FrameSelectors { return jumptToFrame; } - async resolveFrameForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { + private async _resolveFrameForSelector(selector: string, options: types.StrictOptions = {}, scope?: ElementHandle): Promise { let frame: Frame = this.frame; const frameChunks = splitSelectorByFrame(selector); @@ -173,15 +158,65 @@ export class FrameSelectors { return { frame, info: lastChunk, scope }; } - async resolveInjectedForSelector(selector: string, options?: { strict?: boolean, mainWorld?: boolean }, scope?: ElementHandle): Promise<{ injected: JSHandle, info: SelectorInfo, frame: Frame, scope?: ElementHandle } | undefined> { - const resolved = await this.resolveFrameForSelector(selector, options, scope); - // Be careful, |this.frame| can be different from |resolved.frame|. + private async _resolveInjectedForSelector(selector: string, options: types.StrictOptions & { mainWorld?: boolean }, scope?: ElementHandle): Promise<{ frame: Frame, info: SelectorInfo, injected: JSHandle, scope?: ElementHandle } | null> { + const resolved = await this._resolveFrameForSelector(selector, options, scope); if (!resolved) - return; - const context = await resolved.frame.context(options?.mainWorld ? 'main' : resolved.info.world); + return null; + const context = await resolved.frame.context(options.mainWorld ? 'main' : resolved.info.world); const injected = await context.injectedScript(); - return { injected, info: resolved.info, frame: resolved.frame, scope: resolved.scope }; + return { frame: resolved.frame, info: resolved.info, injected, scope: resolved.scope }; } + + async callOnSelector( + selector: string, + options: types.StrictOptions & { mainWorld?: boolean, callWithoutMatches?: boolean, scope?: ElementHandle, markTargets?: 'all' | 'first' | 'none' }, + pageFunction: MatchedElementsCallback, + arg: Arg, + ): Promise<{ frame: Frame, info: SelectorInfo, result: R } | null> { + const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); + if (!resolved) + return null; + const result = await resolved.injected.evaluate(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: !!options.callWithoutMatches, markTargets: options.markTargets }) as R; + // callMatchedElements returns undefined when there were no matches and it skipped the page function. + if (!options.callWithoutMatches && result === undefined) + return null; + return { frame: resolved.frame, info: resolved.info, result }; + } + + async callOnSelectorHandle( + selector: string, + options: types.StrictOptions & { mainWorld?: boolean, scope?: ElementHandle, markTargets?: 'all' | 'first' | 'none' }, + pageFunction: MatchedElementsCallback, + arg: Arg, + ): Promise<{ result: SmartHandle } | null> { + const resolved = await this._resolveInjectedForSelector(selector, options, options.scope); + if (!resolved) + return null; + const result = await resolved.injected.evaluateHandle(callMatchedElements, { info: resolved.info, scope: resolved.scope, functionText: String(pageFunction), arg, callWithoutMatches: false, markTargets: options.markTargets }) as SmartHandle; + // A skipped page function returns undefined, which has no object id (unlike a matched element/object). + if (!result._objectId) { + result.dispose(); + return null; + } + return { result }; + } +} + +function callMatchedElements(injected: InjectedScript, { info, scope, functionText, arg, callWithoutMatches, markTargets }: { info: SelectorInfo, scope: Node | undefined, functionText: string, arg: any, callWithoutMatches: boolean, markTargets?: 'all' | 'first' | 'none' }): any { + const elements = injected.querySelectorAll(info.parsed, scope || document); + if (markTargets === 'all') + injected.markTargetElements(new Set(elements)); + else if (markTargets === 'first' && elements.length) + injected.markTargetElements(new Set([elements[0]])); + else if (markTargets === 'first') + injected.markTargetElements(new Set()); + injected.checkDeprecatedSelectorUsage(info.parsed, elements); + if (info.strict && elements.length > 1) + throw injected.strictModeViolationError(info.parsed, elements); + if (!elements.length && !callWithoutMatches) + return undefined; + const pageFunction = injected.eval('(' + functionText + ')'); + return pageFunction({ injected, elements, info }, arg); } async function adoptIfNeeded(handle: ElementHandle, context: FrameExecutionContext): Promise> { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index bbb253624a7a0..3184bce5f9ee6 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -845,29 +845,25 @@ export class Frame extends SdkObject { if (performActionPreChecksAndLog) await this._page.performActionPreChecks(progress); - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); - if (!resolved) { - if (state === 'hidden' || state === 'detached') - return null; - return continuePolling; - } - const result = await progress.race(resolved.injected.evaluateHandle((injected, { info, root }) => { - if (root && !root.isConnected) - throw injected.createStacklessError('Element is not attached to the DOM'); - const elements = injected.querySelectorAll(info.parsed, root || document); + if (scope && await progress.race(scope.evaluateInUtility(([injected, node]) => node.isConnected, {})) !== true) + throw new dom.NonRecoverableDOMError('Element is not attached to the DOM'); + + const resolved = await progress.race(this.selectors.callOnSelectorHandle(selector, { ...options, scope }, ({ injected, elements }) => { const element: Element | undefined = elements[0]; const visible = element ? injected.utils.isElementVisible(element) : false; let log = ''; - if (elements.length > 1) { - if (info.strict) - throw injected.strictModeViolationError(info.parsed, elements); + if (elements.length > 1) log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`; - } else if (element) { + else if (element) log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`; - } - injected.checkDeprecatedSelectorUsage(info.parsed, elements); return { log, element, visible, attached: !!element }; - }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); + }, {})); + if (!resolved) { + if (state === 'hidden' || state === 'detached') + return null; + return continuePolling; + } + const result = resolved.result; const { log, visible, attached } = await progress.race(result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached }))); if (log) progress.log(log); @@ -887,7 +883,7 @@ export class Frame extends SdkObject { if ((options as any).__testHookBeforeAdoptNode) await progress.race((options as any).__testHookBeforeAdoptNode()); try { - const mainContext = await progress.race(resolved.frame.mainContext()); + const mainContext = await progress.race(element._frame.mainContext()); return await progress.race(element._adoptTo(mainContext)); } catch (e) { return continuePolling; @@ -1210,27 +1206,21 @@ export class Frame extends SdkObject { if (performActionPreChecks) await this._page.performActionPreChecks(progress); - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, { strict: options.strict })); - if (!resolved) { - if (noAutoWaiting) - throw new dom.NonRecoverableDOMError('Element(s) not found'); - return continuePolling; - } - const result = await progress.race(resolved.injected.evaluateHandle((injected, { info }) => { - const elements = injected.querySelectorAll(info.parsed, document); - injected.markTargetElements(new Set(elements)); + const resolved = await progress.race(this.selectors.callOnSelectorHandle(selector, { strict: options.strict, markTargets: 'all' }, ({ injected, elements }) => { const element = elements[0] as Element | undefined; let log = ''; - if (elements.length > 1) { - if (info.strict) - throw injected.strictModeViolationError(info.parsed, elements); + if (elements.length > 1) log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`; - } else if (element) { + else if (element) log = ` locator resolved to ${injected.previewNode(element)}`; - } - injected.checkDeprecatedSelectorUsage(info.parsed, elements); return { log, success: !!element, element }; - }, { info: resolved.info })); + }, {})); + if (!resolved) { + if (noAutoWaiting) + throw new dom.NonRecoverableDOMError('Element(s) not found'); + return continuePolling; + } + const result = resolved.result; const { log, success } = await progress.race(result.evaluate(r => ({ log: r.log, success: r.success }))); if (log) progress.log(log); @@ -1379,21 +1369,15 @@ export class Frame extends SdkObject { } async addHighlight(progress: Progress, selector: string, style?: string) { - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector)); - if (!resolved) - return; - return await progress.race(resolved.injected.evaluate((injected, { info, style }) => { + await progress.race(this.selectors.callOnSelector(selector, { strict: false, callWithoutMatches: true }, ({ injected, info }, style) => { return injected.addHighlight(info.parsed, style); - }, { info: resolved.info, style })); + }, style)); } async removeHighlight(progress: Progress, selector: string) { - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector)); - if (!resolved) - return; - return await progress.race(resolved.injected.evaluate((injected, { info }) => { + await progress.race(this.selectors.callOnSelector(selector, { strict: false, callWithoutMatches: true }, ({ injected, info }) => { return injected.removeHighlight(info.parsed); - }, { info: resolved.info })); + }, {})); } async hideHighlight() { @@ -1420,14 +1404,12 @@ export class Frame extends SdkObject { async isVisibleInternal(progress: Progress, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { try { - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); + const resolved = await progress.race(this.selectors.callOnSelector(selector, { ...options, scope }, ({ injected, elements }) => { + return injected.elementState(elements[0], 'visible').matches; + }, {})); if (!resolved) return false; - return await progress.race(resolved.injected.evaluate((injected, { info, root }) => { - const element = injected.querySelector(info.parsed, root || document, info.strict); - const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' }; - return state.matches; - }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); + return resolved.result; } catch (e) { if (this.isNonRetriableError(e)) throw e; @@ -1574,31 +1556,64 @@ export class Frame extends SdkObject { // The first expect check, a.k.a. one-shot, always finishes - even when progress is aborted. if (noAbort) progress = nullProgress; - const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; + const mainWorld = options.expression === 'to.have.property'; + const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); + const effectiveSelector = selector ?? (options.expression === 'to.match.aria' ? 'body' : ':root'); - const { frame, info } = selectorInFrame || { frame: this, info: undefined }; - const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility'); - const context = await progress.race(frame.context(world)); - const injected = await progress.race(context.injectedScript()); + let received: ExpectReceived | undefined; + let matches = options.isNot; + let missingReceived = false; - const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options }) => { - const elements = info ? injected.querySelectorAll(info.parsed, document) : []; - injected.markTargetElements(new Set(elements)); + // Non-array expectations are strict (callOnSelector throws on multiple); array ones are not. + const resolved = await progress.race(this.selectors.callOnSelector(effectiveSelector, { strict: !isArray, mainWorld, markTargets: 'all' }, async ({ injected, elements }, options) => { const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); - let log = ''; - if (isArray) - log = ` locator resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}`; - else if (elements.length > 1) - throw injected.strictModeViolationError(info!.parsed, elements); - else if (elements.length) - log = ` locator resolved to ${injected.previewNode(elements[0])}`; - if (info) - injected.checkDeprecatedSelectorUsage(info.parsed, elements); + const log = isArray + ? ` locator resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}` + : ` locator resolved to ${injected.previewNode(elements[0])}`; return { log, ...await injected.expect(elements[0], options, elements) }; - }, { info, options })); + }, options)); + + if (resolved) { + received = resolved.result.received; + matches = resolved.result.matches; + if (resolved.result.log) + progressLog(resolved.result.log); + } else { + // When no elements matched the selector, some assertions can still pass. + if (options.expression === 'to.have.count') { + progressLog(` locator resolved to 0 elements`); + received = { value: 0 }; + matches = 0 === options.expectedNumber; + } else if (options.expression.endsWith('.array')) { + progressLog(` locator resolved to 0 elements`); + received = { value: [] }; + matches = (options.expectedText?.length ?? 0) === 0; + } else if (!options.isNot && options.expression === 'to.be.hidden') { + matches = true; + } else if (options.isNot && options.expression === 'to.be.visible') { + matches = false; + } else if (!options.isNot && options.expression === 'to.be.detached') { + matches = true; + } else if (options.isNot && options.expression === 'to.be.attached') { + matches = false; + } else if (options.isNot && options.expression === 'to.be.in.viewport') { + matches = false; + } else { + matches = options.isNot; + missingReceived = true; + } + if (matches === options.isNot && !isArray) { + const context = await progress.race(this.context(mainWorld ? 'main' : 'utility')); + const injected = await progress.race(context.injectedScript()); + const ariaSnapshot = await progress.race(injected.evaluate(injected => { + if (injected.document.body) + return injected.ariaSnapshotForExpectFailure(injected.document.body, { mode: 'default' }); + })); + if (ariaSnapshot) + received = { ...received, ariaSnapshot }; + } + } - if (log) - progressLog(log); // Note: missingReceived avoids `unexpected value "undefined"` when element was not found. if (matches === options.isNot) { lastIntermediateResult.errorMessage = missingReceived ? 'element(s) not found' : undefined; @@ -1762,21 +1777,18 @@ export class Frame extends SdkObject { const callbackText = body.toString(); progress.log(`waiting for ${this._asLocator(selector)}`); const promise = this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { - const resolved = await progress.race(this.selectors.resolveInjectedForSelector(selector, options, scope)); - if (!resolved) - return continuePolling; - const { log, success, value } = await progress.race(resolved.injected.evaluate((injected, { info, callbackText, taskData, root }) => { + const resolved = await progress.race(this.selectors.callOnSelector(selector, { ...options, scope, markTargets: 'first' }, ({ injected, elements }, { callbackText, taskData }) => { const callback = injected.eval(callbackText) as ElementCallback; - const element = injected.querySelector(info.parsed, root || document, info.strict); - if (!element) - return { success: false }; - injected.markTargetElements(new Set([element])); - const value = callback(injected, element, taskData as T); + const element = elements[0]; + const value = callback(injected, element, taskData); if (!value) return { success: false }; const log = ` locator resolved to ${injected.previewNode(element)}`; return { log, success: true, value }; - }, { info: resolved.info, callbackText, taskData, root: resolved.frame === this ? scope : undefined })); + }, { callbackText, taskData })); + if (!resolved) + return continuePolling; + const { log, success, value } = resolved.result; if (log) progress.log(log); if (!success) @@ -1855,7 +1867,7 @@ export class Frame extends SdkObject { }); return { snapshot }; } - const lines = await ariaSnapshotForFrame(progress, this, options.selector || 'body', options); + const lines = await ariaSnapshotForFrame(progress, this, options.selector, options); return { snapshot: lines.join('\n') }; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 1a576e209dd40..92193c60f128e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -105,6 +105,8 @@ export interface PageDelegate { // WebKit hack. shouldToggleStyleSheetToSyncAnimations(): boolean; setDockTile(image: Buffer): Promise; + // Allow Bidi to set different ffmpeg video filter args. + getFFmpegVideoFilterArgs?: (options: { width: number, height: number }) => string; } type EmulatedSize = { screen: types.Size, viewport: types.Size }; @@ -1106,32 +1108,25 @@ export class InitScript extends DisposableObject { } } -export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, selector: string, options: { mode?: 'ai' | 'default', doNotRenderActive?: boolean, depth?: number, boxes?: boolean } = {}): Promise { +export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, selector: string | undefined, options: { mode?: 'ai' | 'default', doNotRenderActive?: boolean, depth?: number, boxes?: boolean } = {}): Promise { const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async (progress, continuePolling) => { try { - const resolved = await progress.race(frame.selectors.resolveInjectedForSelector(selector, { strict: true })); - if (!resolved) - throw new Error(`Selector "${selector}" did not resolve to any element`); - // Note: resolvedFrame might differ from the original |frame|. - const resolvedFrame = resolved.frame; - const snapshotOrRetry = await progress.race(resolved.injected.evaluate((injected, options) => { - const element = injected.querySelector(options.info.parsed, injected.document, options.info.strict); - if (!element) - return true; - return injected.ariaSnapshotWithRefs(element, options); + // Note: the resolved frame might differ from the original |frame|. + const resolved = await progress.race(frame.selectors.callOnSelector(selector || 'body', { strict: true }, ({ injected, elements }, ariaOptions) => { + return injected.ariaSnapshotWithRefs(elements[0], ariaOptions); }, { mode: options.mode ?? 'default', doNotRenderActive: options.doNotRenderActive, - info: resolved.info, depth: options.depth, boxes: options.boxes, })); - if (snapshotOrRetry === true) { - if (selector !== 'body') + if (!resolved) { + if (selector) throw new NonRecoverableDOMError(`Selector "${selector}" does not match any element`); + // Retry only for the main frame "body" being absent, so that `page.ariaSnapshot()` does not fail. return continuePolling; } - return { ...snapshotOrRetry, resolvedFrame }; + return { ...resolved.result, resolvedFrame: resolved.frame }; } catch (e) { if (frame.isNonRetriableError(e)) throw e; diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 22787d0ceee01..8e97ce83aa083 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -39,6 +39,7 @@ export class ProgressController { private _donePromise = new ManualPromise(); private _state: 'before' | 'running' | { error: Error } | 'finished' = 'before'; private _onCallLog?: (message: string) => void; + private _pendingAbortError?: Error; readonly metadata: CallMetadata; private _controller: AbortController; @@ -68,6 +69,9 @@ export class ProgressController { this._state = { error }; this._forceAbortPromise.reject(error); this._controller.abort(error); + } else if (this._state === 'before') { + (error as any)[kAbortErrorSymbol] = true; + this._pendingAbortError = error; } await this._donePromise; } @@ -76,6 +80,7 @@ export class ProgressController { const deadline = timeout ? monotonicTime() + timeout : 0; assert(this._state === 'before'); this._state = 'running'; + let timer: NodeJS.Timeout | undefined; let outerProgress: string | undefined; @@ -139,6 +144,8 @@ export class ProgressController { } try { + if (this._pendingAbortError) + throw this._pendingAbortError; const result = await task(progress); this._state = 'finished'; return result; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index ede46cd5b94ae..c91775849a9be 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -378,17 +378,17 @@ export class Recorder extends EventEmitter implements Instrume return; try { const mainFrame = frame._page.mainFrame(); - const resolved = await mainFrame.selectors.resolveFrameForSelector(this._highlightedElement.selector); + const resolved = await mainFrame.selectors.callOnSelector(this._highlightedElement.selector, { callWithoutMatches: true }, () => {}, {}); // selector couldn't be found, don't highlight anything if (!resolved) return ''; // selector points to no specific frame, highlight in all frames - if (resolved?.frame === mainFrame) + if (resolved.frame === mainFrame) return stringifySelector(resolved.info.parsed); // selector points to this frame, highlight it - if (resolved?.frame === frame) + if (resolved.frame === frame) return stringifySelector(resolved.info.parsed); // selector points to a different frame, highlight nothing diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index 67349e58569ff..e874a5bcdc66e 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -63,18 +63,6 @@ export function mainFrameForAction(pageAliases: Map, actionInConte return page.mainFrame(); } -export async function frameForAction(pageAliases: Map, actionInContext: actions.ActionInContext, action: actions.ActionWithSelector): Promise { - const pageAlias = actionInContext.frame.pageAlias; - const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; - if (!page) - throw new Error('Internal error: page not found'); - const fullSelector = buildFullSelector(actionInContext.frame.framePath, action.selector); - const result = await page.mainFrame().selectors.resolveFrameForSelector(fullSelector); - if (!result) - throw new Error('Internal error: frame not found'); - return result.frame; -} - function isSameAction(a: actions.ActionInContext, b: actions.ActionInContext): boolean { return a.action.name === b.action.name && a.frame.pageAlias === b.frame.pageAlias && a.frame.framePath.join('|') === b.frame.framePath.join('|'); } diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 7fbaa73fd72d4..19ef019a1f82c 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -277,14 +277,9 @@ export class Screenshotter { try { await progress.race(this._page.hideHighlight()); await progress.race(Promise.all((options.mask || []).map(async ({ frame, selector }) => { - const resolved = await frame.selectors.resolveInjectedForSelector(selector, { strict: false }); - if (!resolved) - return; - await resolved.injected.evaluate((injected, { info, color }) => { - const elements = injected.querySelectorAll(info.parsed, injected.document.documentElement); - if (elements.length) - injected.addMaskedElements(elements, color); - }, { info: resolved.info, color: options.maskColor || '#F0F' }); + await frame.selectors.callOnSelector(selector, { strict: false }, ({ injected, elements }, color) => { + injected.addMaskedElements(elements, color); + }, options.maskColor || '#F0F'); }))); return cleanup; } catch (error) { diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index d1608e45e1d0b..292d6d84bef2f 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -30,7 +30,7 @@ import { registry } from './registry'; import type * as types from './types'; import type { ChildProcess } from 'child_process'; import type { Screencast, ScreencastClient } from './screencast'; -import type { Page } from './page'; +import type { Page, PageDelegate } from './page'; const fps = 25; @@ -60,7 +60,7 @@ export class VideoRecorder { const { size } = this._screencast.addClient(this._client); // For video files only, prioritize encoding into the given size, regardless of the actual pixel data. const videoSize = options.size ?? size; - this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, outputFile); + this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, outputFile, this._screencast.page.delegate); this._artifact = new Artifact(this._screencast.page.browserContext, outputFile); return this._artifact; } @@ -108,16 +108,16 @@ class FfmpegVideoRecorder { private _launchPromise: Promise; private _outputFile: string; - constructor(ffmpegPath: string, size: types.Size, outputFile: string) { + constructor(ffmpegPath: string, size: types.Size, outputFile: string, page: PageDelegate) { if (!outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); this._outputFile = outputFile; this._ffmpegPath = ffmpegPath; this._size = size; - this._launchPromise = this._launch().catch(e => e); + this._launchPromise = this._launch(page).catch(e => e); } - private async _launch() { + private async _launch(page: PageDelegate) { await mkdirIfNeeded(this._outputFile); // How to tune the codec: // 1. Read vp8 documentation to figure out the options. @@ -163,7 +163,8 @@ class FfmpegVideoRecorder { const w = this._size.width; const h = this._size.height; - const args = `-loglevel error -f matroska -fpsprobesize 0 -probesize 32 -analyzeduration 0 -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); + const videoFilterArgs = page.getFFmpegVideoFilterArgs?.({ width: w, height: h }) ?? `pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`; + const args = `-loglevel error -f matroska -fpsprobesize 0 -probesize 32 -analyzeduration 0 -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf ${videoFilterArgs}`.split(' '); args.push(this._outputFile); const { launchedProcess, gracefullyClose } = await launchProcess({ diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index d9be54325effd..34f9d6dedaba3 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -24,6 +24,7 @@ import { escapeHTML } from '@isomorphic/stringUtils'; import { jsonStringifyForceASCII } from '@utils/ascii'; import { createGuid } from '@utils/crypto'; import { debugMode } from '@utils/debug'; +import { debugLogger } from '@utils/debugLogger'; import { currentZone } from '@utils/zones'; import { buildErrorContext } from './errorContext'; import { config, testType } from './common'; @@ -740,7 +741,9 @@ class ArtifactsRecorder { await page._wrapApiCall(async () => { this._pageSnapshot = await page.ariaSnapshot({ mode: 'ai', timeout: 5000 }); }, { internal: true }); - } catch {} + } catch (error) { + debugLogger.log('error', `failed to capture aria snapshot: ${error}`); + } } async didCreateRequestContext(context: APIRequestContextImpl) { diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index 20f75bdabd60d..6503009c16820 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -41,6 +41,24 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins if (isTypeScript) { plugins.push( + // Strip "declare" class fields before these plugins run: + // - plugin-proposal-decorators + // - plugin-transform-class-properties + // - plugin-transform-private-methods + // See https://github.com/microsoft/playwright/issues/38586 + [ + (): PluginObj => ({ + name: 'strip-declare-class-fields', + visitor: { + Class(path) { + for (const member of path.get('body.body')) { + if (member.isClassProperty() && member.node.declare) + member.remove(); + } + } + } + }) + ], [require('@babel/plugin-proposal-decorators'), { version: '2023-05' }], [require('@babel/plugin-transform-class-properties')], [require('@babel/plugin-transform-class-static-block')], diff --git a/tests/android/playwright.config.ts b/tests/android/playwright.config.ts index 909e1c0cd687b..a0d7cb5949e35 100644 --- a/tests/android/playwright.config.ts +++ b/tests/android/playwright.config.ts @@ -41,6 +41,7 @@ const config: Config { hasDebugOutput ? ['list'] : ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob'], + ['../config/parquetReporter.ts'], ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ] : [ ['html', { open: 'on-failure' }], diff --git a/tests/config/parquetReporter.ts b/tests/config/parquetReporter.ts new file mode 100644 index 0000000000000..b2dc4cde0ee82 --- /dev/null +++ b/tests/config/parquetReporter.ts @@ -0,0 +1,195 @@ +/** + * 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 fs from 'fs'; +import path from 'path'; + +import { + DuckDBInstance, + LIST, + STRUCT, + VARCHAR, + listValue, + structValue, + timestampValue, +} from '@duckdb/node-api'; + +import { stripAnsi } from './utils'; + +import type { FullConfig, Reporter, TestCase, TestResult } from '@playwright/test/reporter'; +import type { DuckDBAppender } from '@duckdb/node-api'; + +const ANNOTATIONS_TYPE = LIST(STRUCT({ type: VARCHAR, description: VARCHAR })); +const TAGS_TYPE = LIST(VARCHAR); + +class ParquetReporter implements Reporter { + private _config!: FullConfig; + private _runStartedAt = new Date(); + private _results: { test: TestCase, result: TestResult }[] = []; + + printsToStdio() { + return false; + } + + onBegin(config: FullConfig) { + this._config = config; + this._runStartedAt = new Date(); + } + + onTestEnd(test: TestCase, result: TestResult) { + this._results.push({ test, result }); + } + + async onEnd() { + const runId = parseBigInt(process.env.GITHUB_RUN_ID); + const runAttempt = parseInteger(process.env.GITHUB_RUN_ATTEMPT); + const workflowName = process.env.GITHUB_WORKFLOW || null; + const event = process.env.GITHUB_EVENT_NAME || null; + const prNumber = prNumberFromRef(process.env.GITHUB_REF); + const botName = process.env.PWTEST_BOT_NAME || (process.env.PW_TAG ? process.env.PW_TAG.replace(/^@/, '') : ''); + const headSha = process.env.GITHUB_SHA || null; + const headBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || null; + + const instance = await DuckDBInstance.create(':memory:'); + const connection = await instance.connect(); + try { + await connection.run(`CREATE TABLE IF NOT EXISTS test_results ( + run_id BIGINT, + run_attempt INTEGER, + run_started_at TIMESTAMP, + workflow_name VARCHAR, + event VARCHAR, + head_sha VARCHAR, + head_branch VARCHAR, + pr_number INTEGER, + bot_name VARCHAR, + project_name VARCHAR, + test_title VARCHAR, + file VARCHAR, + -- test_id is intentionally omitted since it's a deterministic hash of (project_name, file, test_title) + line INTEGER, + column_number INTEGER, + expected_status VARCHAR, + status VARCHAR, + retry INTEGER, + result_started_at TIMESTAMP, + duration_ms BIGINT, + error_message VARCHAR, + tags VARCHAR[], + annotations STRUCT(type VARCHAR, description VARCHAR)[], +)`); + const appender = await connection.createAppender('test_results'); + for (const { test, result } of this._results) { + const [, projectName, , ...titles] = test.titlePath(); + appendNullableBigInt(appender, runId); + appendNullableInteger(appender, runAttempt); + appendTimestamp(appender, this._runStartedAt); + appendNullableVarchar(appender, workflowName); + appendNullableVarchar(appender, event); + appendNullableVarchar(appender, headSha); + appendNullableVarchar(appender, headBranch); + appendNullableInteger(appender, prNumber); + appender.appendVarchar(botName); + appender.appendVarchar(projectName); + appender.appendVarchar(titles.join(' › ')); + appender.appendVarchar(path.relative(this._config.rootDir, test.location.file)); + appender.appendInteger(test.location.line); + appender.appendInteger(test.location.column); + appender.appendVarchar(test.expectedStatus); + appender.appendVarchar(result.status); + appender.appendInteger(result.retry); + appendTimestamp(appender, result.startTime); + appender.appendBigInt(BigInt(Math.round(result.duration))); + appendNullableVarchar(appender, errorMessage(result)); + appender.appendValue(listValue(test.tags), TAGS_TYPE); + appender.appendValue(listValue(result.annotations.map(annotation => structValue({ + type: annotation.type, + description: annotation.description ?? '', + }))), ANNOTATIONS_TYPE); + appender.endRow(); + } + appender.flushSync(); + appender.closeSync(); + const outputFile = path.resolve(process.cwd(), process.env.PWTEST_PARQUET_OUTPUT_FILE || 'test-results/test-results.parquet'); + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + await connection.run(`COPY test_results TO '${outputFile.replace(/'/g, "''")}' (FORMAT parquet)`); + } finally { + connection.closeSync(); + instance.closeSync(); + } + } +} + +function errorMessage(result: TestResult): string | null { + const messages = (result.errors ?? []) + .map(error => error.message) + .filter((message): message is string => !!message) + .map(message => stripAnsi(message)); + if (!messages.length) + return null; + return messages.join('\n\n'); +} + +function appendNullableVarchar(appender: DuckDBAppender, value: string | null) { + if (value === null) + appender.appendNull(); + else + appender.appendVarchar(value); +} + +function appendNullableInteger(appender: DuckDBAppender, value: number | null) { + if (value === null) + appender.appendNull(); + else + appender.appendInteger(value); +} + +function appendNullableBigInt(appender: DuckDBAppender, value: bigint | null) { + if (value === null) + appender.appendNull(); + else + appender.appendBigInt(value); +} + +function appendTimestamp(appender: DuckDBAppender, value: Date) { + // TIMESTAMP is microseconds since the epoch. + appender.appendTimestamp(timestampValue(BigInt(value.getTime()) * 1000n)); +} + +function parseBigInt(value: string | undefined): bigint | null { + if (!value) + return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +function parseInteger(value: string | undefined): number | null { + if (!value) + return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function prNumberFromRef(ref: string | undefined): number | null { + // On pull_request events GITHUB_REF is `refs/pull//merge`. + const match = ref?.match(/^refs\/pull\/(\d+)\//); + return match ? Number.parseInt(match[1], 10) : null; +} + +export default ParquetReporter; diff --git a/tests/electron/playwright.config.ts b/tests/electron/playwright.config.ts index adcae88b8842a..3d8c4915f461e 100644 --- a/tests/electron/playwright.config.ts +++ b/tests/electron/playwright.config.ts @@ -41,6 +41,7 @@ const config: Config = { ['json', { outputFile: path.join(outputDir, 'report.json') }], // Needed since tests/electron/package.json exists which would otherwise be picked up as tests/electron/ (outputDir) ['blob', { outputDir: path.resolve(__dirname, '../../blob-report') }], + ['../config/parquetReporter.ts'], ] : 'line', tag: process.env.PW_TAG, projects: [], diff --git a/tests/extension/playwright.config.ts b/tests/extension/playwright.config.ts index 502d6430257f0..d0e5bc17a5372 100644 --- a/tests/extension/playwright.config.ts +++ b/tests/extension/playwright.config.ts @@ -25,7 +25,10 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: 'list', + reporter: process.env.CI ? [ + ['list'], + ['../config/parquetReporter.ts'], + ] : 'list', projects: [ { name: 'chromium', use: { mcpBrowser: 'chromium', protocolVersion: 2 } }, { name: 'chromium (legacy v1)', use: { mcpBrowser: 'chromium', protocolVersion: 1 } }, diff --git a/tests/installation/playwright.config.ts b/tests/installation/playwright.config.ts index dbd54e27a7045..e0a99f1a06107 100644 --- a/tests/installation/playwright.config.ts +++ b/tests/installation/playwright.config.ts @@ -25,6 +25,7 @@ const reporters = () => { ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob'], + ['../config/parquetReporter.ts'], ] : [ ['list'], ['html', { open: 'on-failure' }] diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index d4a2c367f6607..50993c43760fc 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -47,6 +47,7 @@ const reporters = () => { ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob'], + ['../config/parquetReporter.ts'], ] : [ ['html', { open: 'on-failure', title: 'Playwright Library Tests' }] ]; diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 848e633297683..3c1cff83e442f 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -334,8 +334,8 @@ it.describe('screencast', () => { } }); - it('should work for popups', async ({ browser, server, browserName, trace }, testInfo) => { - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/14557'); + it('should work for popups', async ({ browser, server, browserName, isBidi }, testInfo) => { + it.fixme(browserName === 'firefox' && !isBidi, 'https://github.com/microsoft/playwright/issues/14557'); const videosPath = testInfo.outputPath(''); const size = { width: 600, height: 400 }; const context = await browser.newContext({ @@ -453,8 +453,8 @@ it.describe('screencast', () => { expect(videoPlayer.videoHeight).toBe(450); }); - it('should be 800x600 with null viewport', async ({ browser, headless, browserName }, testInfo) => { - it.fixme(browserName === 'firefox' && headless, 'Fails in headless on bots'); + it('should be 800x600 with null viewport', async ({ browser, headless, browserName, isBidi }, testInfo) => { + it.fixme(browserName === 'firefox' && headless && !isBidi, 'Fails in headless on bots'); const context = await browser.newContext({ recordVideo: { diff --git a/tests/mcp/playwright.config.ts b/tests/mcp/playwright.config.ts index 69c0419fd5ddc..a3ef9a722f130 100644 --- a/tests/mcp/playwright.config.ts +++ b/tests/mcp/playwright.config.ts @@ -34,6 +34,7 @@ const reporters = () => { ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob', { outputDir: path.join(__dirname, '..', '..', 'blob-report') }], + ['../config/parquetReporter.ts'], ] : [ ['list'] ]; diff --git a/tests/playwright-test/babel.spec.ts b/tests/playwright-test/babel.spec.ts index 0d7572de5ba1a..5843ab7384f7f 100644 --- a/tests/playwright-test/babel.spec.ts +++ b/tests/playwright-test/babel.spec.ts @@ -37,6 +37,37 @@ test('should succeed', async ({ runInlineTest }) => { expect(result.failed).toBe(0); }); +test('should support declare class fields', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/38586' }, +}, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'one-success.spec.ts': ` + import { test, expect } from '@playwright/test'; + + class Base { + constructor(p1, p2) { + this.p1 = p1; + this.p2 = p2; + } + } + + class Derived extends Base { + p1: string; + declare p2: string; + } + + test('works', () => { + const d = new Derived('value1', 'value2'); + expect(d.p1).toBe(undefined); + expect(d.p2).toBe('value2'); + }) + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + expect(result.failed).toBe(0); +}); + test('should treat enums equally', async ({ runInlineTest }) => { const result = await runInlineTest({ 'component.tsx': ` diff --git a/tests/playwright-test/playwright.config.ts b/tests/playwright-test/playwright.config.ts index f8d4d5f3caa1d..cbc1e5a800964 100644 --- a/tests/playwright-test/playwright.config.ts +++ b/tests/playwright-test/playwright.config.ts @@ -27,6 +27,7 @@ const reporters = () => { ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob', { outputDir: path.join(__dirname, '..', '..', 'blob-report') }], + ['../config/parquetReporter.ts'], ] : [ ['list'] ]; diff --git a/tests/webview/playwright.config.ts b/tests/webview/playwright.config.ts index ea16caf4ceb2d..560dc04580964 100644 --- a/tests/webview/playwright.config.ts +++ b/tests/webview/playwright.config.ts @@ -40,6 +40,7 @@ const config: Config