From 6b7c40119c233226301e5cc2769362615569ebeb Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 25 Jun 2026 19:11:42 -0600 Subject: [PATCH 1/5] fix(routing): match URL globs containing '$$' and other replacement patterns (#41471) '$$', '$&', '$`' and "$'" are special in `String.prototype.replace` with a string argument instead, use the function argument form that treats the string argument literally --- packages/isomorphic/urlMatch.ts | 5 ++++- tests/page/interception.spec.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/urlMatch.ts b/packages/isomorphic/urlMatch.ts index 8a7c106727dee..20783d1e6f179 100644 --- a/packages/isomorphic/urlMatch.ts +++ b/packages/isomorphic/urlMatch.ts @@ -249,7 +249,10 @@ function resolveGlobBase(baseURL: string | undefined, match: string): string { let resolved = result.resolved; for (const [token, original] of tokenMap) { const normalize = result.caseInsensitivePart?.includes(token); - resolved = resolved.replace(token, normalize ? original.toLowerCase() : original); + const replacement = normalize ? original.toLowerCase() : original; + // '$$', '$&', '$`' and "$'" are special in String.prototype.replace with a string argument. + // Instead, use the function argument form that treats the string argument literally. + resolved = resolved.replace(token, () => replacement); } match = resolved; } diff --git a/tests/page/interception.spec.ts b/tests/page/interception.spec.ts index ca3b6b0494bf8..a0c5c8cebfbeb 100644 --- a/tests/page/interception.spec.ts +++ b/tests/page/interception.spec.ts @@ -122,6 +122,11 @@ it('should work with glob', async () => { expect(urlMatches('http://playwright.dev', 'http://playwright.dev/?x=y', '?x=y')).toBeTruthy(); expect(urlMatches('http://playwright.dev/foo/', 'http://playwright.dev/foo/bar?x=y', './bar?x=y')).toBeTruthy(); + // '$$', '$&', '$`' and "$'" are special in String.prototype.replace with a string argument. + expect(urlMatches(undefined, 'http://playwright.dev/foo$$bar', 'http://playwright.dev/foo$$bar')).toBeTruthy(); + expect(urlMatches(undefined, 'http://playwright.dev/a$&b', 'http://playwright.dev/a$&b')).toBeTruthy(); + expect(urlMatches('http://playwright.dev', 'http://playwright.dev/p$$q', './p$$q')).toBeTruthy(); + // Case insensitive matching expect(urlMatches(undefined, 'https://playwright.dev/fooBAR', 'HtTpS://pLaYwRiGhT.dEv/fooBAR')).toBeTruthy(); expect(urlMatches('http://ignored', 'https://playwright.dev/fooBAR', 'HtTpS://pLaYwRiGhT.dEv/fooBAR')).toBeTruthy(); From aeee3a395dffe4519cec54d8ba8ca2fcf492cc4d Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 25 Jun 2026 19:12:29 -0600 Subject: [PATCH 2/5] fix(selectors): match `input[type="reset"]` button label as text (#41473) `elementText` exposed the value of `` and `` also renders its value as a visible button label just like the others --- packages/injected/src/selectorUtils.ts | 2 +- tests/page/selectors-text.spec.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/injected/src/selectorUtils.ts b/packages/injected/src/selectorUtils.ts index 6466e1b7b02a3..2a02df62ff9f6 100644 --- a/packages/injected/src/selectorUtils.ts +++ b/packages/injected/src/selectorUtils.ts @@ -68,7 +68,7 @@ export function elementText(cache: Map, root: value = { full: '', normalized: '', immediate: [] }; if (!shouldSkipForTextMatching(root)) { let currentImmediate = ''; - if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) { + if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button' || root.type === 'reset')) { value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] }; } else { for (let child = root.firstChild; child; child = child.nextSibling) { diff --git a/tests/page/selectors-text.spec.ts b/tests/page/selectors-text.spec.ts index 3793cdac5c667..0c0afa7b5ab1d 100644 --- a/tests/page/selectors-text.spec.ts +++ b/tests/page/selectors-text.spec.ts @@ -362,10 +362,11 @@ it('should skip head, script and style', async ({ page }) => { } }); -it('should match input[type=button|submit]', async ({ page }) => { - await page.setContent(``); +it('should match input[type=button|submit|reset]', async ({ page }) => { + await page.setContent(``); expect(await page.$eval(`text=hello`, e => e.outerHTML)).toBe(''); expect(await page.$eval(`text=world`, e => e.outerHTML)).toBe(''); + expect(await page.$eval(`text=clear`, e => e.outerHTML)).toBe(''); }); it('should work for open shadow roots', async ({ page, server }) => { From e2a3f487c43c7c4fc7edddb9a775da05c0803cdd Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 25 Jun 2026 19:13:16 -0600 Subject: [PATCH 3/5] fix(expect): do not attach `-expected` snapshot when `updateShapshots: "none"` (#41474) --- .../src/matchers/toMatchSnapshot.ts | 5 +++-- tests/playwright-test/golden.spec.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index e075b65f98954..d835b1302a7ae 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -187,9 +187,10 @@ class SnapshotHelper { handleMissing(actual: Buffer | string): MatcherResult { const attachments: MatcherAttachment[] = []; const isWriteMissingMode = this.updateSnapshots !== 'none'; - if (isWriteMissingMode) + if (isWriteMissingMode) { writeFileSync(this.expectedPath, actual); - attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); + attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); + } writeFileSync(this.actualPath, actual); attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`; diff --git a/tests/playwright-test/golden.spec.ts b/tests/playwright-test/golden.spec.ts index 57dac49be8157..29444d63845b8 100644 --- a/tests/playwright-test/golden.spec.ts +++ b/tests/playwright-test/golden.spec.ts @@ -161,6 +161,25 @@ test('should generate separate actual results for repeating names', async ({ run ]); }); +test('should not attach a missing expected snapshot when update-snapshots is none', async ({ runInlineTest }) => { + const result = await runInlineTest({ + ...files, + 'a.spec.js': ` + const { test, expect } = require('./helper'); + test.afterEach(async ({}, testInfo) => { + console.log('## ' + JSON.stringify(testInfo.attachments.map(a => a.name))); + }); + test('is a test', ({}) => { + expect.soft('a').toMatchSnapshot('foo.txt'); + }); + ` + }, { 'update-snapshots': 'none' }); + expect(result.exitCode).toBe(1); + const names = result.output.split('\n').filter(l => l.startsWith('## ')).map(l => JSON.parse(l.substring(3)))[0]; + expect(names).toContain('foo-actual.txt'); + expect(names).not.toContain('foo-expected.txt'); +}); + test('should compile with different option combinations', async ({ runTSC }) => { const result = await runTSC({ 'a.spec.ts': ` From a1493506e2837ff7a754038d0e6120c69ea93074 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 25 Jun 2026 19:13:48 -0600 Subject: [PATCH 4/5] fix(screenshot): reject negative clip width/height (#41476) --- .../playwright-core/src/server/screenshotter.ts | 6 +++--- tests/page/page-screenshot.spec.ts | 14 ++++++++++++++ tests/playwright-test/to-have-screenshot.spec.ts | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index c29e9e0b7c5e7..41acde79345fd 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -349,7 +349,7 @@ function trimClipToSize(clip: types.Rect, size: types.Size): types.Rect { y: Math.max(0, Math.min(clip.y + clip.height, size.height)) }; const result = { x: p1.x, y: p1.y, width: p2.x - p1.x, height: p2.y - p1.y }; - assert(result.width && result.height, 'Clipped area is either empty or outside the resulting image'); + assert(result.width > 0 && result.height > 0, 'Clipped area is either empty or outside the resulting image'); return result; } @@ -376,8 +376,8 @@ export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | ' assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); - assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); - assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); + assert(options.clip.width > 0, 'Expected options.clip.width to be greater than 0.'); + assert(options.clip.height > 0, 'Expected options.clip.height to be greater than 0.'); } return format; } diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 12ccb4e693850..3b73945b400a5 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -194,6 +194,20 @@ it.describe('page screenshot', () => { expect(screenshotError.message).toContain('Clipped area is either empty or outside the resulting image'); }); + it('should throw on a negative clip size', async ({ page, server }) => { + await page.setViewportSize({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshotError = await page.screenshot({ + clip: { + x: 50, + y: 50, + width: -100, + height: 100 + } + }).catch(error => error); + expect(screenshotError.message).toContain('Expected options.clip.width to be greater than 0'); + }); + it('should run in parallel', async ({ page, server }) => { await page.setViewportSize({ width: 500, height: 500 }); await page.goto(server.PREFIX + '/grid.html'); diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index d99d293a7b503..9a8945b0143d4 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -233,7 +233,7 @@ test('should fail with proper error when unsupported argument is given', async ( ` }, { 'update-snapshots': true }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`Expected options.clip.width not to be 0`); + expect(result.output).toContain(`Expected options.clip.width to be greater than 0`); }); test('should have scale:css by default', async ({ runInlineTest }, testInfo) => { From 166a44017ccca58e9b49c6daf1255c57491ffac9 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 25 Jun 2026 19:14:20 -0600 Subject: [PATCH 5/5] fix(fetch): include empty-string multipart fields in the request body (#41478) --- packages/playwright-core/src/server/fetch.ts | 2 +- tests/library/browsercontext-fetch.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index f048bd2a3659b..3860effb56267 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -787,7 +787,7 @@ function serializePostData(params: channels.APIRequestContextFetchParams, header for (const field of params.multipartData) { if (field.file) formData.addFileField(field.name, field.file); - else if (field.value) + else if (field.value !== undefined) formData.addField(field.name, field.value); } setHeader(headers, 'content-type', formData.contentTypeHeader(), true); diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 0b755200a662f..21405f2e82ad8 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -1047,6 +1047,7 @@ it('should support multipart/form-data', async function({ context, server }) { context.request.post(server.EMPTY_PAGE, { multipart: { firstName: 'John', + middleName: '', lastName: 'Doe', file } @@ -1056,6 +1057,7 @@ it('should support multipart/form-data', async function({ context, server }) { expect(serverRequest.method).toBe('POST'); expect(serverRequest.headers['content-type']).toContain('multipart/form-data'); expect(fields['firstName']).toBe('John'); + expect(fields['middleName']).toBe(''); expect(fields['lastName']).toBe('Doe'); expect(files['file'].originalFilename).toBe(file.name); expect(files['file'].mimetype).toBe(file.mimeType);