From a0dce0caef9ff0eb6bfbcdcd5dead4c3a21062b7 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 15 Jul 2026 08:39:25 +0200 Subject: [PATCH 1/6] fix(video): avoid MJPEG interlace inference (#41776) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/playwright-core/src/server/ebml.ts | 11 ++++++----- .../src/server/videoRecorder.ts | 2 +- tests/library/video.spec.ts | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/ebml.ts b/packages/playwright-core/src/server/ebml.ts index ce84b2ec3672a..cf58b55007b1d 100644 --- a/packages/playwright-core/src/server/ebml.ts +++ b/packages/playwright-core/src/server/ebml.ts @@ -90,7 +90,7 @@ function element(id: Buffer, payload: Buffer): Buffer { // Emits the Matroska header: EBML head, an unknown-size (streaming) Segment, stream Info with a // 1ms timestamp scale, and a single MJPEG video track. Frames follow as Clusters via writeClusterHeader. -export function writeHeader(width: number, height: number): Buffer { +export function writeHeader(): Buffer { const ebml = element(kEBML, Buffer.concat([ element(kEBMLVersion, uint(1)), element(kEBMLReadVersion, uint(1)), @@ -112,11 +112,12 @@ export function writeHeader(width: number, height: number): Buffer { element(kTrackType, uint(1)), // 1 = video. element(kFlagLacing, uint(0)), element(kCodecID, Buffer.from('V_MJPEG')), - // PixelWidth/PixelHeight are advisory: ffmpeg's mjpeg decoder uses the dimensions encoded in - // each JPEG frame, and the output video filters normalize to the requested size. + // PixelWidth/PixelHeight are mandatory, but the actual JPEG dimensions are not known yet. + // A larger placeholder can make ffmpeg mistake a short progressive JPEG for an interlaced + // field, so use 1x1 and let the MJPEG decoder read the real dimensions from each frame. element(kVideo, Buffer.concat([ - element(kPixelWidth, uint(width)), - element(kPixelHeight, uint(height)), + element(kPixelWidth, uint(1)), + element(kPixelHeight, uint(1)), ])), ])); const tracks = element(kTracks, track); diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index 292d6d84bef2f..65f47b30fedc3 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -189,7 +189,7 @@ class FfmpegVideoRecorder { }); this._process = launchedProcess; this._gracefullyClose = gracefullyClose; - launchedProcess.stdin!.write(writeHeader(w, h)); + launchedProcess.stdin!.write(writeHeader()); } writeFrame(frame: Buffer, timestamp: number) { diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 3c1cff83e442f..df431af1e858a 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -130,6 +130,25 @@ it.describe('screencast', () => { expectRedFrames(videoFile, size); }); + it('should pad a short frame to the video size', async ({ browser }, testInfo) => { + const videoSize = { width: 800, height: 600 }; + const context = await browser.newContext({ + recordVideo: { + dir: testInfo.outputPath(''), + size: videoSize, + }, + viewport: { width: 800, height: 396 }, + }); + const page = await context.newPage(); + await ensureSomeFrames(page); + await context.close(); + + const videoFile = await page.video().path(); + const videoPlayer = new VideoPlayer(videoFile); + expect(videoPlayer.videoWidth).toBe(videoSize.width); + expect(videoPlayer.videoHeight).toBe(videoSize.height); + }); + it('should continue recording main page after popup closes', async ({ browser, browserName }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30837' }); // Firefox does not have a mobile variant and has a large minimum size (500 on windows and 450 elsewhere). From 533e8b28211e776c28d8b8d6638aa2da61e960ff Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 15 Jul 2026 11:55:07 +0200 Subject: [PATCH 2/6] chore(client): merge waiter timeout and signal rejection (#41787) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../playwright-core/src/client/android.ts | 5 ++-- .../src/client/browserContext.ts | 5 ++-- .../playwright-core/src/client/electron.ts | 5 ++-- packages/playwright-core/src/client/frame.ts | 5 ++-- .../playwright-core/src/client/network.ts | 5 ++-- packages/playwright-core/src/client/page.ts | 5 ++-- packages/playwright-core/src/client/waiter.ts | 30 ++++++++----------- packages/playwright-core/src/client/worker.ts | 5 ++-- 8 files changed, 27 insertions(+), 38 deletions(-) diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 5b0e1798907f6..bad1546eab08b 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -272,11 +272,10 @@ export class AndroidDevice extends ChannelOwner i async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise { return await this._wrapApiCall(async () => { - const { timeout, signal } = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.AndroidDevice.Close) waiter.rejectOnEvent(this, Events.AndroidDevice.Close, () => new TargetClosedError()); const result = await waiter.waitForEvent(this, event, predicate as any); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 89c160f78928d..82445b69f0941 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -449,11 +449,10 @@ export class BrowserContext extends ChannelOwner async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { return await this._wrapApiCall(async () => { - const { timeout, signal } = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.BrowserContext.Close) waiter.rejectOnEvent(this, Events.BrowserContext.Close, () => new TargetClosedError(this._effectiveCloseReason())); const result = await waiter.waitForEvent(this, event, predicate as any); diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index c773ac36f6c8e..d433427c57ba2 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -139,11 +139,10 @@ export class ElectronApplication extends ChannelOwner { return await this._wrapApiCall(async () => { - const { timeout, signal } = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.ElectronApplication.Close) waiter.rejectOnEvent(this, Events.ElectronApplication.Close, () => new TargetClosedError()); const result = await waiter.waitForEvent(this, event, predicate as any); diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 6c9de8ecdcfdb..83f1339bf45db 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -129,9 +129,8 @@ export class Frame extends ChannelOwner implements api.Fr waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason()); waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!')); waiter.rejectOnEvent(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this); - const { timeout } = this._page!._timeoutSettings.navigationTimeout(options); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`); - waiter.rejectOnSignal(options.signal); + const timeoutOptions = this._page!._timeoutSettings.navigationTimeout(options); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded.`); return waiter; } diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 7c4fb67cbffa6..4d36f5dd0a8b1 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -804,11 +804,10 @@ export class WebSocket extends ChannelOwner implement async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { return await this._wrapApiCall(async () => { - const { timeout, signal } = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.WebSocket.Error) waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error')); if (event !== Events.WebSocket.Close) diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 1fedf84e23ad9..2d5bfbf14f032 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -477,13 +477,12 @@ export class Page extends ChannelOwner implements api.Page private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise { return await this._wrapApiCall(async () => { - const { timeout, signal } = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); if (logLine) waiter.log(logLine); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.Page.Crash) waiter.rejectOnEvent(this, Events.Page.Crash, new Error('Page crashed')); if (event !== Events.Page.Close) diff --git a/packages/playwright-core/src/client/waiter.ts b/packages/playwright-core/src/client/waiter.ts index a20ae8dc61142..d5a5706deb8cc 100644 --- a/packages/playwright-core/src/client/waiter.ts +++ b/packages/playwright-core/src/client/waiter.ts @@ -80,25 +80,21 @@ export class Waiter { this._rejectOn(promise.then(() => { throw (typeof error === 'function' ? error() : error); }), dispose); } - rejectOnTimeout(timeout: number, message: string) { - if (!timeout) - return; - const { promise, dispose } = waitForTimeout(timeout); - this._rejectOn(promise.then(() => { throw new TimeoutError(message); }), dispose); - } + rejectOnTimeout({ timeout, signal }: channels.TimeoutOptions, timeoutMessage: string) { + if (signal) { + if (signal.aborted) + return this.rejectImmediately(new AbortError(undefined, { cause: signal.reason })); + let rejectPromise: (e: any) => void; + const promise = new Promise((_, reject) => { rejectPromise = reject; }); + const listener = () => rejectPromise!(new AbortError(undefined, { cause: signal.reason })); + signal.addEventListener('abort', listener, { once: true }); + this._rejectOn(promise, () => signal.removeEventListener('abort', listener)); + } - rejectOnSignal(signal: AbortSignal | undefined) { - if (!signal) - return; - if (signal.aborted) { - this.rejectImmediately(new AbortError(undefined, { cause: signal.reason })); - return; + if (timeout) { + const { promise, dispose } = waitForTimeout(timeout); + this._rejectOn(promise.then(() => { throw new TimeoutError(timeoutMessage); }), dispose); } - let rejectPromise: (e: any) => void; - const promise = new Promise((_, reject) => { rejectPromise = reject; }); - const listener = () => rejectPromise!(new AbortError(undefined, { cause: signal.reason })); - signal.addEventListener('abort', listener, { once: true }); - this._rejectOn(promise, () => signal.removeEventListener('abort', listener)); } rejectImmediately(error: Error) { diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index 5b193d6c5bf8e..90987e03a822c 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -89,11 +89,10 @@ export class Worker extends ChannelOwner implements api. async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { return await this._wrapApiCall(async () => { const timeoutSettings = this._page?._timeoutSettings ?? this._context?._timeoutSettings ?? new TimeoutSettings(); - const { timeout, signal } = timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const timeoutOptions = timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const waiter = Waiter.createForEvent(this, event); - waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - waiter.rejectOnSignal(signal); + waiter.rejectOnTimeout(timeoutOptions, `Timeout ${timeoutOptions.timeout}ms exceeded while waiting for event "${event}"`); if (event !== Events.Worker.Close) waiter.rejectOnEvent(this, Events.Worker.Close, () => this._closeErrorWithReason()); const result = await waiter.waitForEvent(this, event, predicate as any); From 84d2d0043cee04279419495c886ae775a7e905f1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 15 Jul 2026 11:55:19 +0200 Subject: [PATCH 3/6] fix(ci): keep Copilot GitHub access read-only (#41791) --- .github/workflows/create_test_report.yml | 3 +-- .github/workflows/fix-flakes-prompt.md | 3 +++ .github/workflows/fix-flakes.yml | 3 +++ .github/workflows/triage.yml | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create_test_report.yml b/.github/workflows/create_test_report.yml index 4ddaaa8bdfb05..be985b4350344 100644 --- a/.github/workflows/create_test_report.yml +++ b/.github/workflows/create_test_report.yml @@ -190,7 +190,7 @@ jobs: if: needs.triage.outputs.has_draft == 'true' runs-on: ubuntu-latest permissions: - issues: write + pull-requests: write env: PR_NUMBER: ${{ needs.triage.outputs.pr_number }} GH_TOKEN: ${{ github.token }} @@ -206,4 +206,3 @@ jobs: run: | printf '\n\nTriaged by the Playwright bot - [agent run](%s)\n\n' "$WORKFLOW_URL" >> output/triage.md gh issue comment "$PR_NUMBER" --repo "${{ github.repository }}" --body-file output/triage.md - diff --git a/.github/workflows/fix-flakes-prompt.md b/.github/workflows/fix-flakes-prompt.md index c56b9dfa02fbd..ba43719927477 100644 --- a/.github/workflows/fix-flakes-prompt.md +++ b/.github/workflows/fix-flakes-prompt.md @@ -4,6 +4,9 @@ Turn CI test-results data into **one** concrete fix: pick a high-impact flaky-or confirm nobody's on it, fix the root cause *or* scope a skip, pick a reviewer, and hand off a single commit that becomes the PR. Fully autonomous — no approval stops. +The GitHub CLI (`gh`) is not authenticated in this job. Do not use it for GitHub API operations; +use GitHub MCP tools instead. + ## 1. Pick one target Query the DB following the patterns in `.claude/skills/playwright-test-results/SKILL.md`. diff --git a/.github/workflows/fix-flakes.yml b/.github/workflows/fix-flakes.yml index 464df570a6faf..740a857bdf70e 100644 --- a/.github/workflows/fix-flakes.yml +++ b/.github/workflows/fix-flakes.yml @@ -58,6 +58,9 @@ jobs: an expensive runner to fix a flaky or red test is most worthwhile right now, and hand off only that runner label. Do NOT fix anything. + The GitHub CLI is not authenticated in this job. Do not run gh; use GitHub MCP tools + for all GitHub reads. + Optimise for the OS with the highest-impact actionable flakiness/reds that isn't already being worked on OR already fixed. A candidate is dead if a fix PR touches it (open, or recently merged/closed) — the DB window still holds the failing runs from diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 62fdbf209555d..7e3a766fef6c4 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -46,6 +46,9 @@ jobs: PROMPT=$(cat < Date: Wed, 15 Jul 2026 11:55:32 +0200 Subject: [PATCH 4/6] feat(expect): support screenshot abort signals (#41792) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/api/class-locatorassertions.md | 6 +++ docs/src/api/class-pageassertions.md | 6 +++ packages/playwright-core/src/client/page.ts | 10 +++-- .../src/matchers/toMatchSnapshot.ts | 3 ++ packages/playwright/types/test.d.ts | 21 +++++++++++ .../to-have-screenshot.spec.ts | 37 +++++++++++++++++++ 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 3a91e540a8a25..5800e5bf315e4 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2008,6 +2008,9 @@ Snapshot name. Must have a `.png` or `.webp` extension, the screenshot is captur ### option: LocatorAssertions.toHaveScreenshot#1.timeout = %%-js-assertions-timeout-%% * since: v1.23 +### option: LocatorAssertions.toHaveScreenshot#1.signal = %%-js-assertions-signal-%% +* since: v1.62 + ### option: LocatorAssertions.toHaveScreenshot#1.animations = %%-screenshot-option-animations-default-disabled-%% * since: v1.23 @@ -2059,6 +2062,9 @@ Note that screenshot assertions only work with Playwright test runner. ### option: LocatorAssertions.toHaveScreenshot#2.timeout = %%-js-assertions-timeout-%% * since: v1.23 +### option: LocatorAssertions.toHaveScreenshot#2.signal = %%-js-assertions-signal-%% +* since: v1.62 + ### option: LocatorAssertions.toHaveScreenshot#2.animations = %%-screenshot-option-animations-default-disabled-%% * since: v1.23 diff --git a/docs/src/api/class-pageassertions.md b/docs/src/api/class-pageassertions.md index 5747aa609b597..22b1b95bfe52a 100644 --- a/docs/src/api/class-pageassertions.md +++ b/docs/src/api/class-pageassertions.md @@ -258,6 +258,9 @@ Snapshot name. Must have a `.png` or `.webp` extension, the screenshot is captur ### option: PageAssertions.toHaveScreenshot#1.timeout = %%-js-assertions-timeout-%% * since: v1.23 +### option: PageAssertions.toHaveScreenshot#1.signal = %%-js-assertions-signal-%% +* since: v1.62 + ### option: PageAssertions.toHaveScreenshot#1.animations = %%-screenshot-option-animations-default-disabled-%% * since: v1.23 @@ -314,6 +317,9 @@ Note that screenshot assertions only work with Playwright test runner. ### option: PageAssertions.toHaveScreenshot#2.timeout = %%-js-assertions-timeout-%% * since: v1.23 +### option: PageAssertions.toHaveScreenshot#2.signal = %%-js-assertions-signal-%% +* since: v1.62 + ### option: PageAssertions.toHaveScreenshot#2.animations = %%-screenshot-option-animations-default-disabled-%% * since: v1.23 diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 2d5bfbf14f032..f7f2ba405427b 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -19,6 +19,7 @@ import fs from 'fs'; import * as inspector from 'inspector'; import path from 'path'; +import { assertionAbortedMessage } from '@isomorphic/abortSignal'; import { assert } from '@isomorphic/assert'; import { headersObjectToArray } from '@isomorphic/headers'; import { trimStringWithEllipsis } from '@isomorphic/stringUtils'; @@ -32,7 +33,7 @@ import { Coverage } from './coverage'; import { DisposableObject, DisposableStub } from './disposable'; import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; -import { PlaywrightError, TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; +import { AbortError, PlaywrightError, TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; import { Events } from './events'; import { FileChooser } from './fileChooser'; import { Frame, verifyLoadState } from './frame'; @@ -77,6 +78,7 @@ export type ExpectScreenshotOptions = Omit implements api.Page } async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> { - const { timeout, ...optionsWithoutTimeout } = options; + const { timeout, signal, ...optionsWithoutTimeout } = options; const mask = options?.mask ? options?.mask.map(locator => ({ frame: (locator as Locator)._frame._channel, selector: (locator as Locator)._selector, @@ -636,9 +638,11 @@ export class Page extends ChannelOwner implements api.Page isNot: !!options.isNot, locator, mask, - }, { signal: undefined, timeout }); + }, { timeout, signal }); return { actual: result.actual }; } catch (e) { + if (e instanceof AbortError) + return { errorMessage: 'Error: ' + assertionAbortedMessage(e.cause) }; if (!(e instanceof PlaywrightError)) throw e; const details = e.details as channels.PageExpectScreenshotErrorDetails; diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 2b7a088c95c8d..2b79dec0674c4 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -55,6 +55,7 @@ type ToHaveScreenshotOptions = ToHaveScreenshotConfigOptions & { mask?: Array; maskColor?: string; omitBackground?: boolean; + signal?: AbortSignal; }; // Keep in sync with above (begin). @@ -64,6 +65,7 @@ const NonConfigProperties: (keyof ToHaveScreenshotOptions)[] = [ 'mask', 'maskColor', 'omitBackground', + 'signal', ]; // Keep in sync with above (end). @@ -362,6 +364,7 @@ export async function toHaveScreenshot( style, isNot: !!this.isNot, timeout, + signal: helper.options.signal, type: screenshotType, comparator: helper.options.comparator, maxDiffPixels: helper.options.maxDiffPixels, diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index d30c7f240de59..093da901d0804 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -9618,6 +9618,13 @@ interface LocatorAssertions { */ scale?: "css"|"device"; + /** + * An optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can cancel the + * assertion. Aborting the signal fails the assertion like a timeout: if the signal is aborted while the assertion is + * retrying, or is already aborted before the assertion starts, the assertion fails without retrying further. + */ + signal?: AbortSignal; + /** * File name containing the stylesheet to apply while making the screenshot. This is where you can hide dynamic * elements, make elements invisible or change their properties to help you creating repeatable screenshots. This @@ -9714,6 +9721,13 @@ interface LocatorAssertions { */ scale?: "css"|"device"; + /** + * An optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can cancel the + * assertion. Aborting the signal fails the assertion like a timeout: if the signal is aborted while the assertion is + * retrying, or is already aborted before the assertion starts, the assertion fails without retrying further. + */ + signal?: AbortSignal; + /** * File name containing the stylesheet to apply while making the screenshot. This is where you can hide dynamic * elements, make elements invisible or change their properties to help you creating repeatable screenshots. This @@ -10605,6 +10619,13 @@ export interface PageAssertionsToHaveScreenshotOptions { */ scale?: "css"|"device"; + /** + * An optional [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that can cancel the + * assertion. Aborting the signal fails the assertion like a timeout: if the signal is aborted while the assertion is + * retrying, or is already aborted before the assertion starts, the assertion fails without retrying further. + */ + signal?: AbortSignal; + /** * File name containing the stylesheet to apply while making the screenshot. This is where you can hide dynamic * elements, make elements invisible or change their properties to help you creating repeatable screenshots. This diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index 79f33f51825cd..22551fa95d1c8 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -60,6 +60,43 @@ test('should fail to screenshot a page with infinite animation', async ({ runInl expect(fs.existsSync(testInfo.outputPath('a.spec.js-snapshots', 'is-a-test-1.png'))).toBe(false); }); +test('should fail like a timeout when aborted', async ({ runInlineTest }) => { + const infiniteAnimationURL = pathToFileURL(path.join(__dirname, '../assets/rotate-z.html')); + const result = await runInlineTest({ + ...playwrightConfig({}), + 'a.spec.js': ` + const { test, expect } = require('@playwright/test'); + test('is a test', async ({ page }) => { + await page.goto('${infiniteAnimationURL}'); + const controller = new AbortController(); + const promise = expect(page).toHaveScreenshot({ animations: 'allow', timeout: 5000, signal: controller.signal }); + await page.waitForTimeout(500); + controller.abort(new Error('stop it')); + await promise; + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain(`operation was aborted: stop it`); + expect(result.output).not.toContain(`Timeout 5000ms exceeded`); +}); + +test('should fail when already aborted', async ({ runInlineTest }) => { + const result = await runInlineTest({ + ...playwrightConfig({}), + 'a.spec.js': ` + const { test, expect } = require('@playwright/test'); + test('is a test', async ({ page }) => { + const controller = new AbortController(); + controller.abort(new Error('already aborted')); + await expect(page).toHaveScreenshot({ signal: controller.signal }); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain(`Error: The assertion was aborted: already aborted`); +}); + test('should disable animations by default', async ({ runInlineTest }, testInfo) => { const cssTransitionURL = pathToFileURL(path.join(__dirname, '../assets/css-transition.html')); const result = await runInlineTest({ From b6e3e37ac0c899e8fa8f2cc73813925c9adad6ca Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 15 Jul 2026 11:55:45 +0200 Subject: [PATCH 5/6] chore(expect): preserve _expect call shape (#41790) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/playwright-core/src/client/frame.ts | 4 +- .../playwright-core/src/client/locator.ts | 4 +- packages/playwright-core/src/client/types.ts | 2 +- packages/playwright/src/matchers/matchers.ts | 68 +++++++++---------- .../src/matchers/toMatchAriaSnapshot.ts | 6 +- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 83f1339bf45db..ad25c267dd8e3 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -495,8 +495,8 @@ export class Frame extends ChannelOwner implements api.Fr return (await this._channel.title({}, kNoTimeout)).value; } - async _expect(expression: string, options: Omit & { timeout: number }, signal: AbortSignal | undefined): Promise { - const { timeout, ...rest } = options; + async _expect(expression: string, options: Omit & { timeout: number, signal?: AbortSignal }): Promise { + const { timeout, signal, ...rest } = options; const params: channels.FrameExpectParams = { expression, ...rest, isNot: !!rest.isNot }; params.expectedValue = serializeArgument(rest.expectedValue); try { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 8794dd09399e7..342799550bc40 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -404,11 +404,11 @@ export class Locator implements api.Locator { } - async _expect(expression: string, options: FrameExpectParams, signal: AbortSignal | undefined): Promise { + async _expect(expression: string, options: FrameExpectParams): Promise { return this._frame._expect(expression, { ...options, selector: this._selector, - }, signal); + }); } private _inspect() { diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index dfa07bc012723..f340521105e1a 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -147,4 +147,4 @@ export type AnnotateOptions = { duration?: number, position?: AnnotatePosition, export type RemoteAddr = channels.RemoteAddr; export type SecurityDetails = channels.SecurityDetails; -export type FrameExpectParams = Omit & { expectedValue?: any, timeout: number }; +export type FrameExpectParams = Omit & { expectedValue?: any, timeout: number, signal?: AbortSignal }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 72d86657d73bd..0bdf1b3ac7710 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -66,11 +66,11 @@ function serializeExpectedTextValues(items: (string | RegExp)[], options: { matc export interface LocatorEx extends Locator { _selector: string; - _expect(expression: string, options: FrameExpectParams, signal: AbortSignal | undefined): Promise; + _expect(expression: string, options: FrameExpectParams): Promise; } export interface FrameEx extends Frame { - _expect(expression: string, options: FrameExpectParams, signal: AbortSignal | undefined): Promise; + _expect(expression: string, options: FrameExpectParams): Promise; } interface APIResponseEx extends APIResponse { @@ -86,7 +86,7 @@ export function toBeAttached( const expected = attached ? 'attached' : 'detached'; const arg = attached ? '' : '{ attached: false }'; return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout }, signal); + return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout, signal }); }, options); } @@ -111,7 +111,7 @@ export function toBeChecked( arg = options?.checked === false ? `{ checked: false }` : ''; } return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect('to.be.checked', { isNot, timeout, expectedValue }, signal); + return await locator._expect('to.be.checked', { isNot, timeout, expectedValue, signal }); }, options); } @@ -121,7 +121,7 @@ export function toBeDisabled( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.disabled', { isNot, timeout }, signal); + return await locator._expect('to.be.disabled', { isNot, timeout, signal }); }, options); } @@ -134,7 +134,7 @@ export function toBeEditable( const expected = editable ? 'editable' : 'readOnly'; const arg = editable ? '' : '{ editable: false }'; return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout }, signal); + return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout, signal }); }, options); } @@ -144,7 +144,7 @@ export function toBeEmpty( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.empty', { isNot, timeout }, signal); + return await locator._expect('to.be.empty', { isNot, timeout, signal }); }, options); } @@ -157,7 +157,7 @@ export function toBeEnabled( const expected = enabled ? 'enabled' : 'disabled'; const arg = enabled ? '' : '{ enabled: false }'; return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout }, signal); + return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout, signal }); }, options); } @@ -167,7 +167,7 @@ export function toBeFocused( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.focused', { isNot, timeout }, signal); + return await locator._expect('to.be.focused', { isNot, timeout, signal }); }, options); } @@ -177,7 +177,7 @@ export function toBeHidden( options?: { timeout?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.hidden', { isNot, timeout }, signal); + return await locator._expect('to.be.hidden', { isNot, timeout, signal }); }, options); } @@ -190,7 +190,7 @@ export function toBeVisible( const expected = visible ? 'visible' : 'hidden'; const arg = visible ? '' : '{ visible: false }'; return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout, signal) => { - return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout }, signal); + return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout, signal }); }, options); } @@ -200,7 +200,7 @@ export function toBeInViewport( options?: { timeout?: number, ratio?: number, signal?: AbortSignal }, ) { return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout, signal) => { - return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout }, signal); + return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout, signal }); }, options); } @@ -213,12 +213,12 @@ export function toContainText( if (Array.isArray(expected)) { return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout }, signal); + return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal }); }, expected, { ...options, contains: true }); } else { return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout }, signal); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options.useInnerText, timeout, signal }); }, expected, { ...options, matchSubstring: true }); } } @@ -231,7 +231,7 @@ export function toHaveAccessibleDescription( ) { return toMatchText.call(this, 'toHaveAccessibleDescription', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.description', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.accessible.description', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -243,7 +243,7 @@ export function toHaveAccessibleName( ) { return toMatchText.call(this, 'toHaveAccessibleName', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.name', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.accessible.name', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -255,7 +255,7 @@ export function toHaveAccessibleErrorMessage( ) { return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true }); - return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout, signal }); }, expected, options); } @@ -275,12 +275,12 @@ export function toHaveAttribute( } if (expected === undefined) { return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout, signal) => { - return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout }, signal); + return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout, signal }); }, options); } return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected as (string | RegExp)], { ignoreCase: options?.ignoreCase }); - return await locator._expect('to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.attribute.value', { expressionArg: name, expectedText, isNot, timeout, signal }); }, expected as (string | RegExp), options); } @@ -293,12 +293,12 @@ export function toHaveClass( if (Array.isArray(expected)) { return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.have.class.array', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.class.array', { expectedText, isNot, timeout, signal }); }, expected, options); } else { return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.class', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.class', { expectedText, isNot, timeout, signal }); }, expected, options); } } @@ -314,14 +314,14 @@ export function toContainClass( throw new Error(`"expected" argument in toContainClass cannot contain RegExp values`); return toEqual.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.contain.class.array', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.contain.class.array', { expectedText, isNot, timeout, signal }); }, expected, options); } else { if (isRegExp(expected)) throw new Error(`"expected" argument in toContainClass cannot be a RegExp value`); return toMatchText.call(this, 'toContainClass', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.contain.class', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.contain.class', { expectedText, isNot, timeout, signal }); }, expected, options); } } @@ -333,7 +333,7 @@ export function toHaveCount( options?: { timeout?: number, signal?: AbortSignal }, ) { return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout, signal) => { - return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout }, signal); + return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout, signal }); }, expected, options); } @@ -347,7 +347,7 @@ export function toHaveCSS( ) { return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, pseudo: options?.pseudo, timeout }, signal); + return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, pseudo: options?.pseudo, timeout, signal }); }, expected, options); } @@ -359,7 +359,7 @@ export function toHaveId( ) { return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.id', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.id', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -371,7 +371,7 @@ export function toHaveJSProperty( options?: { timeout?: number, signal?: AbortSignal }, ) { return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout, signal) => { - return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout }, signal); + return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout, signal }); }, expected, options); } @@ -385,7 +385,7 @@ export function toHaveRole( throw new Error(`"role" argument in toHaveRole must be a string`); return toMatchText.call(this, 'toHaveRole', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.role', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.role', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -398,12 +398,12 @@ export function toHaveText( if (Array.isArray(expected)) { return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }, signal); + return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal }); }, expected, options); } else { return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }, signal); + return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout, signal }); }, expected, options); } } @@ -416,7 +416,7 @@ export function toHaveValue( ) { return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected]); - return await locator._expect('to.have.value', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.value', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -428,7 +428,7 @@ export function toHaveValues( ) { return toEqual.call(this, 'toHaveValues', locator, 'Locator', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues(expected); - return await locator._expect('to.have.values', { expectedText, isNot, timeout }, signal); + return await locator._expect('to.have.values', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -440,7 +440,7 @@ export function toHaveTitle( ) { return toMatchText.call(this, 'toHaveTitle', page, 'Page', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { normalizeWhiteSpace: true }); - return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout }, signal); + return await (page.mainFrame() as FrameEx)._expect('to.have.title', { expectedText, isNot, timeout, signal }); }, expected, options); } @@ -461,7 +461,7 @@ export function toHaveURL( expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected; return toMatchText.call(this, 'toHaveURL', page, 'Page', async (isNot, timeout, signal) => { const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase }); - return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout }, signal); + return await (page.mainFrame() as FrameEx)._expect('to.have.url', { expectedText, isNot, timeout, signal }); }, expected, options); } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index b7d89048793a9..ab5b13ea0ee9e 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -91,10 +91,10 @@ export async function toMatchAriaSnapshot( if (globalChildren && !expected.match(/^- \/children:/m)) expected = `- /children: ${globalChildren}\n` + expected; - const expectParams = { expectedValue: expected, isNot: this.isNot, timeout }; + const expectParams = { expectedValue: expected, isNot: this.isNot, timeout, signal: options.signal }; const { matches: pass, received, log, timedOut, errorMessage } = locator ? - await (locator as LocatorEx)._expect('to.match.aria', expectParams, options.signal) : - await ((receiver as Page).mainFrame() as FrameEx)._expect('to.match.aria', expectParams, options.signal); + await (locator as LocatorEx)._expect('to.match.aria', expectParams) : + await ((receiver as Page).mainFrame() as FrameEx)._expect('to.match.aria', expectParams); const typedReceived = received?.value as MatcherReceived; const message = () => { From e0e814deed7b0a4c4d2bdf98481e6be7419cda16 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 15 Jul 2026 11:56:07 +0200 Subject: [PATCH 6/6] chore(protocol): promote timeout to call metadata (#41786) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../playwright-core/src/server/dispatchers/dispatcher.ts | 5 +---- packages/playwright-core/src/server/instrumentation.ts | 1 + .../playwright-core/src/server/trace/recorder/tracing.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 9215be6d89070..172a58368b278 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -356,13 +356,10 @@ export class DispatcherConnection { type: dispatcher._type, method, params: params || {}, + timeout: validMetadata.timeout, log: [], }; - // TODO(skn0tt): promote to top-level metadata instead of smuggling through params. - if (validMetadata.timeout) - callMetadata.params = { ...callMetadata.params, timeout: validMetadata.timeout }; - const controller = dispatcher.createProgressController(callMetadata); this._activeProgressControllers.set(callMetadata.id, controller); diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index c5730e81a84fc..449782e268418 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -90,6 +90,7 @@ export type CallMetadata = { type: string; method: string; params: any; + timeout?: number; title?: string; // Client is making an internal call that should not show up in // the inspector or trace. diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 90a7018dac724..5fc90c8560c7d 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -738,7 +738,7 @@ function createBeforeActionTraceEvent(metadata: CallMetadata, parentId?: string) title: metadata.title, class: metadata.type, method: metadata.method, - params: metadata.params, + params: metadata.timeout ? { ...metadata.params, timeout: metadata.timeout } : metadata.params, stepId: metadata.stepId, pageId: metadata.pageId, };