From 109c4784b75ca7e04feb7f1cbc5ecf427ea3e022 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 26 Jun 2026 11:16:03 -0700 Subject: [PATCH 01/10] fix(dashboard): route the --port dashboard through the singleton (#41466) --- .../src/tools/dashboard/dashboardApp.ts | 156 +++++++++--------- .../tools/dashboard/dashboardController.ts | 34 +--- tests/mcp/annotate.spec.ts | 18 ++ tests/mcp/cli-fixtures.ts | 17 +- tests/mcp/dashboard.spec.ts | 12 +- 5 files changed, 122 insertions(+), 115 deletions(-) diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index fb35e817467cb..72d2c319dd42f 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -42,7 +42,7 @@ declare const __PW_HMR__: boolean; type DashboardServer = { url: string; - reveal: (options: DashboardOptions) => Promise; + reveal: (options: DashboardOptions) => void; triggerAnnotate: (signal: AbortSignal) => Promise; close: () => Promise; }; @@ -86,21 +86,22 @@ async function startDashboardServer(provider: SessionProvider, options: Dashboar attachDashboardStaticServer(httpServer, dashboardDir); await httpServer.start({ port: options.port, host: options.host }); - const reveal = async (next: DashboardOptions): Promise => { - await connectionLanded; - await Promise.all([...connections].map(async c => { - if (next.pageId) - await c.revealPage(next.pageId); - else if (next.sessionName) - await c.revealSession(next.sessionName, next.workspaceDir); - })); + const reveal = (next: DashboardOptions): void => { + void connectionLanded.then(() => { + for (const c of connections) { + if (next.pageId) + c.revealPage(next.pageId); + else if (next.sessionName) + c.revealSession(next.sessionName, next.workspaceDir); + } + }); }; const triggerAnnotate = async (cancellation: AbortSignal): Promise => { await connectionLanded; if (cancellation.aborted || connections.size === 0) return { type: 'cancelled' }; - // Multiple dashboard connections is theoretical today (one UI per daemon), server mode does not support annotate. + // Multiple dashboard connections is theoretical today (one UI per daemon). // If two ever land, the first to submit wins but the losers stay in // annotation mode until their UI reloads — revisit if that becomes a real // scenario. @@ -135,13 +136,7 @@ async function attachDashboardDevServer(httpServer: HttpServer) { } // HMR end -async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { - const server = await startDashboardServer(new RegistrySessionProvider(), options); - void server.reveal(options).catch(() => {}); - const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) }); - await page.goto(server.url); - return { page, server }; -} +type AppState = { page?: api.Page; server: DashboardServer }; async function launchApp(appName: string, options?: { onClose?: () => void }) { const channel = findChromiumChannelBestEffort('javascript'); @@ -240,13 +235,15 @@ type AcquireResult = | { role: 'winner', server: net.Server } | { role: 'loser', daemonPid: number }; -async function acquireSingleton(options: DashboardOptions): Promise { +async function acquireSingleton(options: DashboardOptions, onConnection: (socket: net.Socket) => void): Promise { const socketPath = dashboardSocketPath(); if (process.platform !== 'win32') await fs.promises.mkdir(path.dirname(socketPath), { recursive: true }); return await new Promise((resolve, reject) => { - const server = net.createServer(); + // Attach the connection handler at creation — before listen() — so a loser + // that connects the instant we win the socket is never dropped. + const server = net.createServer(onConnection); server.listen(socketPath, () => resolve({ role: 'winner', server })); server.on('error', (err: NodeJS.ErrnoException) => { if (err.code !== 'EADDRINUSE' && err.code !== 'EEXIST') @@ -287,20 +284,13 @@ export async function openDashboardApp() { // eslint-disable-next-line no-console console.error('Unhandled promise rejection:', error); }); - if (options.port !== undefined) { - const server = await startDashboardServer(new RegistrySessionProvider(), options); - void server.reveal(options).catch(() => {}); - // eslint-disable-next-line no-console - console.log(`Listening on ${server.url}`); - // eslint-disable-next-line no-restricted-properties - await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed. - selfDestructOnParentGone(); - return; - } + // Self-destruct if the parent CLI dies before we signal READY. Unregistered // before we signal so the daemon outlives the parent. const stopSelfDestruct = selfDestructOnParentGone(); - const acquired = await acquireSingleton(options); + + const statePromise = new ManualPromise(); + const acquired = await acquireSingleton(options, socket => handleConnection(socket, statePromise)); if (acquired.role === 'loser') { // Another daemon is already running, signal success. stopSelfDestruct(); @@ -313,10 +303,24 @@ export async function openDashboardApp() { const { server } = acquired; process.on('exit', () => server.close()); try { - await startApp(server, options); - stopSelfDestruct(); - // eslint-disable-next-line no-console - console.log(`Dashboard is running pid=${process.pid}`); + const dashboard = await startDashboardServer(new RegistrySessionProvider(), options); + dashboard.reveal(options); + if (options.port !== undefined) { + // Server mode serves HTTP in the foreground and stays tied to the parent CLI. + statePromise.resolve({ server: dashboard }); + // eslint-disable-next-line no-console + console.log(`Listening on ${dashboard.url}`); + } else { + // Windowed daemon launches a browser window and detaches from the parent CLI. + const { page } = await launchApp('dashboard', { onClose: () => gracefullyProcessExitDoNotHang(0) }); + await page.goto(dashboard.url); + statePromise.resolve({ page, server: dashboard }); + stopSelfDestruct(); + // eslint-disable-next-line no-console + console.log(`Dashboard is running pid=${process.pid}`); + } + // eslint-disable-next-line no-restricted-properties + await new Promise(f => process.stdout.write('', f)); // Make sure stdout is flushed. } catch (error) { // eslint-disable-next-line no-console console.log(error); @@ -324,55 +328,47 @@ export async function openDashboardApp() { } } -async function startApp(server: net.Server, options: DashboardOptions) { - const statePromise = innerOpenDashboardApp(options); - server.on('connection', socket => { - let buffer = ''; - socket.on('data', async data => { - buffer += data.toString(); - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex === -1) - return; - const line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - let parsed: DashboardOptions | undefined; +function handleConnection(socket: net.Socket, statePromise: Promise) { + let buffer = ''; + socket.on('data', async data => { + buffer += data.toString(); + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) + return; + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + let parsed: DashboardOptions | undefined; + try { + parsed = JSON.parse(line); + } catch { + // no-op + } + if (!parsed) { + socket.end(); + return; + } + const { page, server: dashboard } = await statePromise; + if (parsed.annotate) { + const cancellation = new AbortController(); + socket.on('close', () => cancellation.abort()); + socket.on('error', () => cancellation.abort()); try { - parsed = JSON.parse(line); - } catch { - // no-op + await page?.bringToFront(); + dashboard.reveal(parsed); + const result = await dashboard.triggerAnnotate(cancellation.signal); + socket.end(JSON.stringify(result)); + } catch (e) { + socket.end(e); } - if (!parsed) { - socket.end(); - return; - } - const { page, server: dashboard } = await statePromise; - if (parsed.annotate) { - const cancellation = new AbortController(); - socket.on('close', () => cancellation.abort()); - socket.on('error', () => cancellation.abort()); - try { - await page?.bringToFront(); - await dashboard.reveal(parsed); - const result = await dashboard.triggerAnnotate(cancellation.signal); - socket.end(JSON.stringify(result)); - } catch (e) { - socket.end(e); - } - } else if (parsed.kill) { - await dashboard.close().catch(() => {}); - gracefullyProcessExitDoNotHang(0, () => new Promise(r => socket.end(r))); - } else { - try { - await page?.bringToFront(); - await dashboard.reveal(parsed); - socket.end(JSON.stringify({ pid: process.pid }) + '\n'); - } catch (e) { - socket.end(e); - } - } - }); + } else if (parsed.kill) { + await dashboard.close().catch(() => {}); + gracefullyProcessExitDoNotHang(0, () => new Promise(r => socket.end(r))); + } else { + void page?.bringToFront().catch(() => {}); + dashboard.reveal(parsed); + socket.end(JSON.stringify({ pid: process.pid }) + '\n'); + } }); - await statePromise; } export async function openDashboardForContext(context: api.BrowserContext): Promise { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index c87e35e720049..210586802221e 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -20,7 +20,6 @@ import fs from 'fs'; import crypto from 'crypto'; import { execFile } from 'child_process'; import { Disposable } from '@isomorphic/disposable'; -import { ManualPromise } from '@isomorphic/manualPromise'; import { eventsHelper } from '@utils/eventsHelper'; import { createClientInfo } from '../cli-client/registry'; @@ -46,7 +45,7 @@ export class DashboardConnection implements Transport { private _onconnected?: () => void; private _pushTabsScheduled = false; private _visible = true; - private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string; done: ManualPromise } | undefined; + private _pendingReveal: { sessionName?: string; workspaceDir?: string; pageId?: string } | undefined; private _pendingAnnotate: { resolve: (result: AnnotateResult) => void; dispose: () => void } | undefined; _recordingDir: string; @@ -83,8 +82,6 @@ export class DashboardConnection implements Transport { this._provider.dispose(); this._attachedPage?.dispose(); this._attachedPage = undefined; - // Reject any in-flight reveal so callers awaiting it don't hang. - this._pendingReveal?.done.reject(new Error('Dashboard connection closed')); this._pendingReveal = undefined; this._resolvePendingAnnotate({ type: 'cancelled' }); for (const stream of this._streams.values()) { @@ -144,29 +141,14 @@ export class DashboardConnection implements Transport { await this._attachedPage?.setScreencastActive(params.visible); } - revealSession(sessionName: string, workspaceDir?: string): Promise { - const existing = this._pendingReveal; - if (existing - && existing.pageId === undefined - && existing.sessionName === sessionName - && existing.workspaceDir === workspaceDir) - return existing.done; - existing?.done.reject(new Error('Reveal superseded')); - const done = new ManualPromise(); - this._pendingReveal = { sessionName, workspaceDir, done }; + revealSession(sessionName: string, workspaceDir?: string) { + this._pendingReveal = { sessionName, workspaceDir }; void this._tryRevealPending(); - return done; } - revealPage(pageId: string): Promise { - const existing = this._pendingReveal; - if (existing && existing.pageId === pageId) - return existing.done; - existing?.done.reject(new Error('Reveal superseded')); - const done = new ManualPromise(); - this._pendingReveal = { pageId, done }; + revealPage(pageId: string) { + this._pendingReveal = { pageId }; void this._tryRevealPending(); - return done; } private async _tryRevealPending() { @@ -188,10 +170,8 @@ export class DashboardConnection implements Transport { try { await this._switchAttachedTo(page); this._pushTabs(); - pending.done.resolve(); - } catch (e) { - pending.done.reject(e instanceof Error ? e : new Error(String(e))); - throw e; + } catch { + // Best-effort: a failed reveal leaves the dashboard on its current page. } } diff --git a/tests/mcp/annotate.spec.ts b/tests/mcp/annotate.spec.ts index 0f00ca9d11213..db69c71b430b6 100644 --- a/tests/mcp/annotate.spec.ts +++ b/tests/mcp/annotate.spec.ts @@ -248,6 +248,24 @@ test('should capture annotations via show --annotate', async ({ connectToDashboa verifyAnnotateOutput(output, 'hello', test.info().outputDir); }); +test('should route annotate to a running port dashboard', async ({ cli, server, startDashboardServer }) => { + await cli('open', server.EMPTY_PAGE); + + const dashboard = await startDashboardServer(); + await dashboard.getByRole('navigation', { name: 'Sessions' }).getByRole('option').first().click(); + + const annotatePromise = cli('show', '--annotate'); + let done = false; + void annotatePromise.finally(() => { done = true; }); + + await drawAndSubmitAnnotation(dashboard, 'routed'); + + const { output, exitCode } = await annotatePromise; + expect(done).toBe(true); + expect(exitCode).toBe(0); + verifyAnnotateOutput(output, 'routed', test.info().outputDir); +}); + test('should start dashboard and annotate when no dashboard is running', async ({ connectToDashboard, cli, server }) => { const bindTitle = `--playwright-internal--${crypto.randomUUID()}`; await cli('open', server.EMPTY_PAGE, { bindTitle }); diff --git a/tests/mcp/cli-fixtures.ts b/tests/mcp/cli-fixtures.ts index 11ef3ee974206..408018e6338de 100644 --- a/tests/mcp/cli-fixtures.ts +++ b/tests/mcp/cli-fixtures.ts @@ -48,13 +48,7 @@ export const test = baseTest.extend<{ }, startDashboardServer: async ({ childProcess, page }, use) => { await use(async (options?: { cwd?: string, session?: string }) => { - const testInfo = test.info(); - const showArgs = options?.session ? [`-s=${options.session}`, 'show'] : ['show']; - const serverProcess = childProcess({ - command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), ...showArgs, '--port=0'], - cwd: options?.cwd ?? testInfo.outputPath(), - env: inheritAndCleanEnv(cliEnv()), - }); + const serverProcess = spawnDashboardServer(childProcess, options); await serverProcess.waitForOutput('Listening on '); await page.goto(serverProcess.output.match(/Listening on (http:\/\/\S+)/)![1]); return page; @@ -117,6 +111,15 @@ export const test = baseTest.extend<{ }, }); +export function spawnDashboardServer(childProcess: CommonFixtures['childProcess'], options?: { cwd?: string, session?: string }) { + const showArgs = options?.session ? [`-s=${options.session}`, 'show'] : ['show']; + return childProcess({ + command: [process.execPath, require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'), ...showArgs, '--port=0'], + cwd: options?.cwd ?? test.info().outputPath(), + env: inheritAndCleanEnv(cliEnv()), + }); +} + function cliEnv() { return { PWTEST_SERVER_REGISTRY: test.info().outputPath('registry'), diff --git a/tests/mcp/dashboard.spec.ts b/tests/mcp/dashboard.spec.ts index eda77af07cce1..4437988873f2c 100644 --- a/tests/mcp/dashboard.spec.ts +++ b/tests/mcp/dashboard.spec.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { test, expect, installSaveFilePickerMock } from './cli-fixtures'; +import { test, expect, installSaveFilePickerMock, spawnDashboardServer } from './cli-fixtures'; function displayPath(p: string): string { const home = os.homedir(); @@ -94,6 +94,8 @@ test('should show current workspace sessions first', async ({ cli, server, start await checkOrder(wsA, wsB); }); + await cli('show', '--kill'); + await test.step('open dashboard in workspace B', async () => { await checkOrder(wsB, wsA); }); @@ -185,3 +187,11 @@ test('two concurrent cli show invocations both succeed', async ({ cli }) => { expect(first.dashboardPid).toBe(second.dashboardPid); await cli('show', '--kill'); }); + +test('port dashboard owns the singleton and receives kill', async ({ cli, childProcess }) => { + const serverProcess = spawnDashboardServer(childProcess); + await serverProcess.waitForOutput('Listening on '); + await cli('show', '--kill'); + const { exitCode } = await serverProcess.exited; + expect(exitCode).toBe(0); +}); From 5c65ff297100c0358233603e1503e18225aaa5f2 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:01:49 -0700 Subject: [PATCH 02/10] fix(connect): wrong parameter used when passed as an object (#41484) the TS overloads make it so that `options` is only set when `optionsOrEndpoint` is a string otherwise, `optionsOrEndpoint` is the options object --- .../playwright-core/src/client/browserType.ts | 2 +- tests/library/browsertype-connect.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index e31fa27bf7381..06c0ce17d5e8e 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -127,7 +127,7 @@ export class BrowserType extends ChannelOwner imple if (typeof optionsOrEndpoint === 'string') return await this._connect({ ...options, endpoint: optionsOrEndpoint }); assert(optionsOrEndpoint.wsEndpoint, 'options.wsEndpoint is required'); - return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint }); + return await this._connect({ ...optionsOrEndpoint, endpoint: optionsOrEndpoint.wsEndpoint }); } async _connect(params: ConnectOptions): Promise { diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 46fe27051d115..a6e764fc3e8ec 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -264,6 +264,21 @@ for (const kind of ['launchServer', 'run-server'] as const) { expect(request.headers['foo']).toBe('bar'); }); + test('should send extra headers with connect request in object form', async ({ browserType, server }) => { + const requestPromise = server.waitForWebSocketConnectionRequest(); + browserType.connect({ + wsEndpoint: `ws://localhost:${server.PORT}/ws`, + headers: { + 'User-Agent': 'Playwright', + 'foo': 'bar', + }, + timeout: 3000, + }).catch(() => {}); + const request = await requestPromise; + expect(request.headers['user-agent']).toBe('Playwright'); + expect(request.headers['foo']).toBe('bar'); + }); + test('should send default User-Agent and X-Playwright-Browser headers with connect request', async ({ connect, browserName, server, isFrozenWebkit }) => { test.skip(isFrozenWebkit); From 85b2732aff58878356a2d7cb11d5fd2bfec3bed2 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:02:13 -0700 Subject: [PATCH 03/10] fix: read HTTP header values case-insensitively in HAR and fetch (#41483) some servers send lowercase headers, so allow for them (and any other casing) by always lowercasing before lookup --- packages/playwright-core/src/server/fetch.ts | 2 +- .../src/server/har/harTracer.ts | 40 ++++++++++++------- tests/library/browsercontext-fetch.spec.ts | 20 ++++++++++ tests/library/har.spec.ts | 27 +++++++++++++ 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 3860effb56267..672094e0055c3 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -504,7 +504,7 @@ export abstract class APIRequestContext extends SdkObject { let body: Readable = response; let transform: Transform | undefined; - const encoding = response.headers['content-encoding']; + const encoding = response.headers['content-encoding']?.toLowerCase(); if (encoding === 'gzip' || encoding === 'x-gzip') { transform = zlib.createGunzip({ flush: zlib.constants.Z_SYNC_FLUSH, diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 3514e6334f4b9..b461d4189ec1d 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -215,7 +215,8 @@ export class HarTracer { if (!this._options.omitCookies) harEntry.request.cookies = event.cookies; harEntry.request.headers = Object.entries(event.headers).map(([name, value]) => ({ name, value })); - harEntry.request.postData = this._postDataForBuffer(event.postData || null, event.headers['content-type'], this._options.content); + const contentType = Object.entries(event.headers).find(([name]) => name.toLowerCase() === 'content-type')?.[1]; + harEntry.request.postData = this._postDataForBuffer(event.postData || null, contentType, this._options.content); if (!this._options.omitSizes) harEntry.request.bodySize = event.postData?.length || 0; (event as any)[this._entrySymbol] = harEntry; @@ -800,20 +801,29 @@ function parseCookie(c: string): har.Cookie { continue; } - if (name === 'Domain') - cookie.domain = value; - if (name === 'Expires') - cookie.expires = safeDateToISOString(value); - if (name === 'HttpOnly') - cookie.httpOnly = true; - if (name === 'Max-Age') - cookie.expires = safeDateToISOString(Date.now() + (+value) * 1000); - if (name === 'Path') - cookie.path = value; - if (name === 'SameSite') - cookie.sameSite = value; - if (name === 'Secure') - cookie.secure = true; + switch (name.toLowerCase()) { + case 'domain': + cookie.domain = value; + break; + case 'expires': + cookie.expires = safeDateToISOString(value); + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'max-age': + cookie.expires = safeDateToISOString(Date.now() + (+value) * 1000); + break; + case 'path': + cookie.path = value; + break; + case 'samesite': + cookie.sameSite = value; + break; + case 'secure': + cookie.secure = true; + break; + } } return cookie; } diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 21405f2e82ad8..aac4b3646b24e 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -786,6 +786,26 @@ it('should support gzip compression', async function({ context, server }) { expect(await response.text()).toBe('Hello, world!'); }); +it('should support case-insensitive content-encoding', async function({ context, server }) { + server.setRoute('/compressed-uppercase', (req, res) => { + res.writeHead(200, { + 'Content-Encoding': 'GZIP', + 'Content-Type': 'text/plain', + }); + + const gzip = zlib.createGzip(); + pipeline(gzip, res, err => { + if (err) + console.log(`Server error: ${err}`); + }); + gzip.write('Hello, world!'); + gzip.end(); + }); + + const response = await context.request.get(server.PREFIX + '/compressed-uppercase'); + expect(await response.text()).toBe('Hello, world!'); +}); + it('should throw informative error on corrupted gzip body', async function({ context, server }) { server.setRoute('/corrupted', (req, res) => { res.writeHead(200, { diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 83bac48caf5f7..469826a112aeb 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -248,6 +248,18 @@ it('should include set-cookies', async ({ contextFactory, server }, testInfo) => expect(new Date(cookies[2].expires!).valueOf()).toBeGreaterThan(Date.now()); }); +it('should include set-cookies with lowercase attributes', async ({ contextFactory, server }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Set-Cookie', ['name=value; path=/; httponly; secure; samesite=Lax']); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const log = await getLog(); + const cookies = log.entries[0].response.cookies; + expect(cookies[0]).toEqual({ name: 'name', value: 'value', path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }); +}); + it('should skip invalid Expires', async ({ contextFactory, server }, testInfo) => { const { page, getLog } = await pageWithHar(contextFactory, testInfo); server.setRoute('/empty.html', (req, res) => { @@ -1091,6 +1103,21 @@ it.describe('tracing.startHar', () => { expect(log.entries.some(e => e.request.url === server.PREFIX + '/simple.json')).toBe(true); }); + it('should record mixed-case request content-type for APIRequestContext', async ({ playwright, server }, testInfo) => { + server.setRoute('/post', (req, res) => res.end('ok')); + const request = await playwright.request.newContext(); + const harPath = testInfo.outputPath('api-post.har.zip'); + await request.tracing.startHar(harPath, { content: 'attach' }); + await request.post(server.PREFIX + '/post', { headers: { 'Content-Type': 'application/json' }, data: Buffer.from('{"a":1}') }); + await request.tracing.stopHar(); + await request.dispose(); + + const resources = await parseHar(harPath); + const log = JSON.parse(resources.get('har.har')!.toString()).log as Log; + const entry = log.entries.find(e => e.request.url.endsWith('/post'))!; + expect(entry.request.postData!.mimeType).toBe('application/json'); + }); + it('should record a HAR with resourcesDir', async ({ contextFactory, server }, testInfo) => { const context = await contextFactory(); const harPath = testInfo.outputPath('tracing.har'); From 8b25dffd9da918b04a7738747e139a27ba6ceb98 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:02:42 -0700 Subject: [PATCH 04/10] fix(mcp): list a failed network request only once (#41481) `_handleRequest` already records every request (and `_handleResponse` does not re-add it) --- packages/playwright-core/src/tools/backend/tab.ts | 1 - tests/mcp/network.spec.ts | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/tools/backend/tab.ts b/packages/playwright-core/src/tools/backend/tab.ts index bb819501fe89a..09df4cba187fb 100644 --- a/packages/playwright-core/src/tools/backend/tab.ts +++ b/packages/playwright-core/src/tools/backend/tab.ts @@ -244,7 +244,6 @@ export class Tab extends EventEmitter { } private _handleRequestFailed(request: playwright.Request) { - this._requests.push(request); const timing = request.timing(); const wallTime = timing.responseEnd + timing.startTime; this._addLogEntry({ type: 'request', wallTime, request }); diff --git a/tests/mcp/network.spec.ts b/tests/mcp/network.spec.ts index f72045db6ac05..e68dd9a47628c 100644 --- a/tests/mcp/network.spec.ts +++ b/tests/mcp/network.spec.ts @@ -207,6 +207,21 @@ test('browser_network_request reports failed requests', async ({ client, server expect(detail!.result).toContain('status: [404]'); }); +test('browser_network_requests lists a failed request once', async ({ client, server }) => { + server.setContent('/', ``, 'text/html'); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + const list = parseResponse(await client.callTool({ + name: 'browser_network_requests', + arguments: { static: true }, + })); + expect([...list!.result!.matchAll(/\/api\/x =>/g)]).toHaveLength(1); +}); + test('browser_network_request returns individual parts', async ({ client, server }) => { server.setContent('/', ` From 9cef3e510ce8c9cfcd2bb69b27afe112de059d95 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:04:44 -0700 Subject: [PATCH 05/10] fix(expect): clone `pollIntervals` so they are not consumed across calls (#41480) `pollAgainstDeadline` mutates the provided `pollIntervals` both `expect.poll` and `expect.toPass` pass it by reference the former uses a per-test config, whereas the latter uses a per-project config that's shared by every test (in that worker) --- packages/isomorphic/timeoutRunner.ts | 2 +- tests/playwright-test/expect-to-pass.spec.ts | 21 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/timeoutRunner.ts b/packages/isomorphic/timeoutRunner.ts index b001c6bc0399f..4ffe5d86ae8a8 100644 --- a/packages/isomorphic/timeoutRunner.ts +++ b/packages/isomorphic/timeoutRunner.ts @@ -39,7 +39,7 @@ export async function raceAgainstDeadline(cb: () => Promise, deadline: num }); } -export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { +export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, [...pollIntervals]: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { const lastPollInterval = pollIntervals.pop() ?? 1000; let lastResult: T|undefined; const wrappedCallback = () => Promise.resolve().then(callback); diff --git a/tests/playwright-test/expect-to-pass.spec.ts b/tests/playwright-test/expect-to-pass.spec.ts index c3d16639db9ff..f5e4072c295db 100644 --- a/tests/playwright-test/expect-to-pass.spec.ts +++ b/tests/playwright-test/expect-to-pass.spec.ts @@ -114,6 +114,27 @@ test('should respect interval', async ({ runInlineTest }) => { expect(result.exitCode).toBe(0); }); +test('should not consume the shared toPass intervals across tests', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { expect: { toPass: { intervals: [0, 0, 0] } } }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + for (const name of ['one', 'two']) { + test(name, async () => { + let probes = 0; + await test.expect(() => { + ++probes; + expect(probes).toBeGreaterThanOrEqual(3); + }).toPass({ timeout: 1000 }); + }); + } + ` + }, { workers: 1 }); + expect(result.passed).toBe(2); +}); + test('should compile', async ({ runTSC }) => { const result = await runTSC({ 'a.spec.ts': ` From fd9b11f2db303d45ddb9f189b62f5094a5c6a50a Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:05:08 -0700 Subject: [PATCH 06/10] fix(selectors): don't drop the engine name when a non-xpath source starts with ".." (#41475) --- packages/isomorphic/selectorParser.ts | 2 +- tests/page/page-strict.spec.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index c5c322cb7a361..9da6c48cd0a8c 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -132,7 +132,7 @@ export function stringifySelector(selector: string | ParsedSelector, forceEngine if (!forceEngineName && i !== selector.capture) { if (p.name === 'css') includeEngine = false; - else if (p.name === 'xpath' && p.source.startsWith('//') || p.source.startsWith('..')) + else if (p.name === 'xpath' && (p.source.startsWith('//') || p.source.startsWith('..'))) includeEngine = false; } const prefix = includeEngine ? p.name + '=' : ''; diff --git a/tests/page/page-strict.spec.ts b/tests/page/page-strict.spec.ts index e85697d6d6e4c..afbc3171167a1 100644 --- a/tests/page/page-strict.spec.ts +++ b/tests/page/page-strict.spec.ts @@ -119,3 +119,10 @@ it('should escape tag names', async ({ page }) => { expect(error.message).toContain(`getByText('special test description').first()`); expect(error.message).toContain(`locator('q\\\\:template').filter({ hasText: 'special test description' })`); }); + +it('should keep the engine name for a "text=.." selector in strict mode', async ({ page }) => { + await page.setContent(`
..loading
..loading
`); + const error = await page.locator('text=..loading').hover().catch(e => e); + expect(error.message).toContain('strict mode violation'); + expect(error.message).toContain(`locator('text=..loading')`); +}); From da747ec24da6253dd3b92770e434a0db38fc34cd Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 12:07:14 -0700 Subject: [PATCH 07/10] fix(aria): use placeholder for the accessible name of `` (#41485) see --- packages/injected/src/roleUtils.ts | 2 +- tests/library/role-utils.spec.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index 1c1bfb8635f29..f18665902a852 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -827,7 +827,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options); - const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || tagName === 'TEXTAREA'; + const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'number', 'search', 'tel', 'email', 'url'].includes((element as HTMLInputElement).type)) || tagName === 'TEXTAREA'; const placeholder = element.getAttribute('placeholder') || ''; const title = element.getAttribute('title') || ''; if (!usePlaceholder || title) diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index d894edbb29f31..7006f4d37244c 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -299,6 +299,7 @@ test('native controls', async ({ page }) => { + @@ -316,6 +317,7 @@ test('native controls', async ({ page }) => { expect.soft(await getNameAndRole(page, '#text1')).toEqual({ role: 'textbox', name: 'TEXT1' }); expect.soft(await getNameAndRole(page, '#text2')).toEqual({ role: 'textbox', name: 'TEXT2' }); expect.soft(await getNameAndRole(page, '#text3')).toEqual({ role: 'textbox', name: 'TEXT3' }); + expect.soft(await getNameAndRole(page, '#number1')).toEqual({ role: 'spinbutton', name: 'NUMBER1' }); expect.soft(await getNameAndRole(page, '#image1')).toEqual({ role: 'button', name: 'IMAGE1' }); expect.soft(await getNameAndRole(page, '#image2')).toEqual({ role: 'button', name: 'IMAGE2' }); expect.soft(await getNameAndRole(page, '#image3')).toEqual({ role: 'button', name: 'IMAGE3' }); From d7acba59a6fb2e91300da0e6ee540e3221c55654 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 26 Jun 2026 20:21:15 +0100 Subject: [PATCH 08/10] test: unflake a few tests (#41488) --- tests/library/client-certificates.spec.ts | 12 ++++++++--- tests/library/har-websocket.spec.ts | 26 +++++++++++------------ tests/library/har.spec.ts | 2 +- tests/mcp/http.spec.ts | 10 +++------ tests/page/page-screenshot.spec.ts | 2 ++ 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 1507dad77c832..f1d1715a1cd33 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -345,7 +345,7 @@ test.describe('browser', () => { test('should not intercept TLS for origins without a client certificate', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41106' }, - }, async ({ browser, asset, httpsServer }) => { + }, async ({ browser, asset, httpsServer, browserName, platform }) => { // If the proxy intercepted this origin, the browser would see its self-signed cert (CN=localhost) // instead of the real server cert (CN=playwright-test). const page = await browser.newPage({ @@ -357,8 +357,14 @@ test.describe('browser', () => { }); const response = await page.goto(httpsServer.EMPTY_PAGE); expect(response.ok()).toBe(true); - // This is "CN=playwright-test" in some ubuntu webkits, and "playwright-test" in other browsers. - expect((await response.securityDetails()).subjectName).toContain('playwright-test'); + const subjectName = (await response.securityDetails()).subjectName; + if (browserName === 'webkit' && platform === 'win32') { + // Don't ask me why this is "true" on Windows WebKit. + expect(subjectName).toContain('true'); + } else { + // This is "CN=playwright-test" in some ubuntu webkits, and "playwright-test" in other browsers. + expect(subjectName).toContain('playwright-test'); + } await page.close(); }); diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts index c13896216d705..20f780bd585a0 100644 --- a/tests/library/har-websocket.spec.ts +++ b/tests/library/har-websocket.spec.ts @@ -252,24 +252,24 @@ it('should attach websocket messages for a still open websocket after stopping', const beforeMs = Date.now(); const wsUrl = `ws://${server.HOST}/ws`; - const [ws] = await Promise.all([ - page.waitForEvent('websocket'), - page.evaluate(({ url, outgoingText, outgoingBinary }) => { - const ws = new WebSocket(url); - (window as any).ws = ws; - let count = 0; - ws.addEventListener('open', () => ws.send(outgoingText)); - ws.addEventListener('message', () => { - if (++count < 2) - ws.send(new Uint8Array(outgoingBinary)); - }); - }, { url: wsUrl, outgoingText, outgoingBinary }), - ]); + const wsPromise = page.waitForEvent('websocket'); + const evaluatePromise = page.evaluate(({ url, outgoingText, outgoingBinary }) => { + const ws = new WebSocket(url); + (window as any).ws = ws; + let count = 0; + ws.addEventListener('open', () => ws.send(outgoingText)); + ws.addEventListener('message', () => { + if (++count < 2) + ws.send(new Uint8Array(outgoingBinary)); + }); + }, { url: wsUrl, outgoingText, outgoingBinary }); + const ws = await wsPromise; // Wait for all frames so the HAR tracer has observed them before the context is closed. await ws.waitForEvent('framesent'); await ws.waitForEvent('framereceived'); await ws.waitForEvent('framesent'); await ws.waitForEvent('framereceived'); + await evaluatePromise; const afterMs = Date.now(); // Do not close the WebSocket on the page side. Closing the context should still flush messages. diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 469826a112aeb..6550c5bc79003 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -705,7 +705,7 @@ it('should return security details directly from response', async ({ contextFact const response = await page.goto(httpsServer.EMPTY_PAGE); const securityDetails = await response!.securityDetails(); if (browserName === 'webkit' && platform === 'win32') - expect({ ...securityDetails, protocol: undefined }).toEqual({ subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); + expect({ ...securityDetails, protocol: undefined }).toEqual({ subjectName: 'true', validFrom: 1691708270, validTo: 2007068270 }); else if (browserName === 'webkit') expect(securityDetails).toEqual({ protocol: 'TLS 1.3', subjectName: 'playwright-test', validFrom: 1691708270, validTo: 2007068270 }); else diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 0f761b8ce43c8..292e7e1275aea 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -350,13 +350,9 @@ test('client should receive list roots request', async ({ serverEndpoint, server const { url } = await serverEndpoint(); const transport = new StreamableHTTPClientTransport(url); const client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: { roots: {} } }); - let rootsListedCallback; - const rootsListedPromise = new Promise((resolve, reject) => { - rootsListedCallback = resolve; - setTimeout(() => reject(new Error('timeout waiting for ListRootsRequestSchema')), 5_000); - }); + const requests = []; client.setRequestHandler(ListRootsRequestSchema, async request => { - rootsListedCallback('success'); + requests.push(request); return { roots: [ { @@ -371,7 +367,7 @@ test('client should receive list roots request', async ({ serverEndpoint, server name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); - expect(await rootsListedPromise).toBe('success'); + await expect.poll(() => requests).toEqual([{ method: 'roots/list' }]); }); test('should close session when heartbeat ping is not answered', async ({ serverEndpoint, server }) => { diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 3b73945b400a5..4fc5008de9d4c 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -441,6 +441,8 @@ it.describe('page screenshot', () => { }); it('should take fullPage screenshots during navigation', async ({ page, server }) => { + it.slow(); + await page.setViewportSize({ width: 500, height: 500 }); await page.goto(server.PREFIX + '/grid.html'); const reloadSeveralTimes = async () => { From ee02af885fa430740b8e98ccfe56bfaf06734504 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 14:31:16 -0700 Subject: [PATCH 09/10] fix(har): `APIRequestContext` cookie expires should be in ms (#41482) --- .../src/server/har/harTracer.ts | 2 +- tests/library/har.spec.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index b461d4189ec1d..c888a33307450 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -256,7 +256,7 @@ export class HarTracer { harEntry.response.cookies = this._options.omitCookies ? [] : event.cookies.map(c => { return { ...c, - expires: c.expires === -1 ? undefined : safeDateToISOString(c.expires) + expires: c.expires === -1 ? undefined : safeDateToISOString(c.expires * 1000) }; }); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 6550c5bc79003..e3b11c77d4606 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -1103,6 +1103,25 @@ it.describe('tracing.startHar', () => { expect(log.entries.some(e => e.request.url === server.PREFIX + '/simple.json')).toBe(true); }); + it('should record correct cookie expires for APIRequestContext', async ({ playwright, server }, testInfo) => { + server.setRoute('/set-cookie', (req, res) => { + res.setHeader('Set-Cookie', 'name=value; Expires=Tue, 01 Jan 2030 00:00:00 GMT'); + res.end('hello'); + }); + const request = await playwright.request.newContext(); + const harPath = testInfo.outputPath('api.har.zip'); + await request.tracing.startHar(harPath, { content: 'attach' }); + await request.get(server.PREFIX + '/set-cookie'); + await request.tracing.stopHar(); + await request.dispose(); + + const resources = await parseHar(harPath); + const log = JSON.parse(resources.get('har.har')!.toString()).log as Log; + const entry = log.entries.find(e => e.request.url.endsWith('/set-cookie'))!; + const cookie = entry.response.cookies.find(c => c.name === 'name')!; + expect(new Date(cookie.expires!).getUTCFullYear()).toBe(2030); + }); + it('should record mixed-case request content-type for APIRequestContext', async ({ playwright, server }, testInfo) => { server.setRoute('/post', (req, res) => res.end('ok')); const request = await playwright.request.newContext(); From 287ad476bb1383c1ffeaf9d21677dc438809ab56 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 26 Jun 2026 14:34:43 -0700 Subject: [PATCH 10/10] fix(routing): `urlMatches` should reset `lastIndex` if given `/.../g` or `/.../y` (#41470) global `/.../g` and sticky `/.../y` regular expressions silently adjust `lastIndex` this means that subsequent uses will miss any matches before the new `lastIndex` (until it wraps around) as such, we should reset the `lastIndex` before trying to use it --- packages/isomorphic/urlMatch.ts | 4 ++-- tests/page/interception.spec.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/isomorphic/urlMatch.ts b/packages/isomorphic/urlMatch.ts index 20783d1e6f179..455147898098f 100644 --- a/packages/isomorphic/urlMatch.ts +++ b/packages/isomorphic/urlMatch.ts @@ -181,8 +181,8 @@ export function urlMatches(baseURL: string | undefined, urlString: string, match if (isString(match)) match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl)); if (isRegExp(match)) { - const r = match.test(urlString); - return r; + match.lastIndex = 0; + return match.test(urlString); } const url = parseURL(urlString); if (!url) diff --git a/tests/page/interception.spec.ts b/tests/page/interception.spec.ts index a0c5c8cebfbeb..0d3fbdd02e074 100644 --- a/tests/page/interception.spec.ts +++ b/tests/page/interception.spec.ts @@ -299,6 +299,19 @@ it('should work with regular expression passed from a different context', async expect(intercepted).toBe(true); }); +it('should intercept every request matching a global regexp', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + let intercepted = 0; + await page.route(/\/intercept-me/g, async route => { + ++intercepted; + await route.fulfill({ body: 'intercepted' }); + }); + const url = server.PREFIX + '/intercept-me'; + for (let i = 0; i < 3; ++i) + expect(await page.evaluate(u => fetch(u, { cache: 'no-store' }).then(r => r.text()), url)).toBe('intercepted'); + expect(intercepted).toBe(3); +}); + it('should not break remote worker importScripts', async ({ page, server }) => { await page.route('**', async route => { await route.continue();