From 5f469be22b3ab0ee02ac0e3148a665fba69cbfd9 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 10 Aug 2026 22:08:43 +0200 Subject: [PATCH 1/3] fix(cli): hash unix socket basenames to stay within sun_path (#42185) --- packages/utils/fileUtils.ts | 11 +++++------ tests/mcp/cli-misc.spec.ts | 7 +++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/utils/fileUtils.ts b/packages/utils/fileUtils.ts index 327ac6e8028c3..613f298cc4a8a 100644 --- a/packages/utils/fileUtils.ts +++ b/packages/utils/fileUtils.ts @@ -126,12 +126,11 @@ export function makeSocketPath(domain: string, name: string): string { } const baseDir = process.env.PWTEST_SOCKETS_DIR || path.join(os.tmpdir(), `pw-${userNameHash}`); const dir = path.join(baseDir, domain); - const suffix = '.sock'; - const maxNameLength = UNIX_SOCKET_PATH_MAX - dir.length - path.sep.length - suffix.length; - if (maxNameLength < 1) - throw new Error(`Socket directory path is too long (${dir.length} chars); set PWTEST_SOCKETS_DIR to a shorter location.`); - const fsFriendlyName = trimLongString(sanitizeForFilePath(name), maxNameLength); - const result = path.join(dir, `${fsFriendlyName}${suffix}`); + let result = path.join(dir, sanitizeForFilePath(name) + '.sock'); + if (Buffer.byteLength(result) > UNIX_SOCKET_PATH_MAX) + result = path.join(dir, calculateSha1(name).slice(0, 16) + '.sock'); + if (Buffer.byteLength(result) > UNIX_SOCKET_PATH_MAX) + throw new Error(`Socket directory path is too long (${Buffer.byteLength(dir)} bytes); set PWTEST_SOCKETS_DIR to a shorter location.`); fs.mkdirSync(dir, { recursive: true }); return result; } diff --git a/tests/mcp/cli-misc.spec.ts b/tests/mcp/cli-misc.spec.ts index 750f34c1a5fe5..a3e8630d56634 100644 --- a/tests/mcp/cli-misc.spec.ts +++ b/tests/mcp/cli-misc.spec.ts @@ -102,3 +102,10 @@ test('open with very long session name (issue 40878)', async ({ cli, server }) = expect(result.exitCode).toBe(0); expect(result.output).toContain('Page URL'); }); + +test('open with long multi-byte session name (issue 42153)', async ({ cli, server }) => { + const result = await cli('-s=セッション名がとても長い場合の動作を確認するためのテスト', 'open', server.PREFIX); + expect(result.error).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Page URL'); +}); From 53344b3c83a2b87edeeb7b4af4940d254b749561 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 10 Aug 2026 22:11:44 +0200 Subject: [PATCH 2/3] feat(test-runner): annotate serial suites for custom sharding (#42164) --- packages/playwright/src/common/suiteUtils.ts | 2 + .../reporter-preprocess.spec.ts | 38 +++++++++++++++++++ tests/playwright-test/test-modifiers.spec.ts | 9 +++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/playwright/src/common/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index 5748da9cf1f14..1248aedc92d35 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -57,6 +57,8 @@ export function bindFileSuiteToProject(project: FullProjectInternal, suite: Suit for (let parentSuite: Suite | undefined = suite; parentSuite; parentSuite = parentSuite.parent) { if (parentSuite._staticAnnotations.length) test.annotations.unshift(...parentSuite._staticAnnotations); + if (parentSuite._parallelMode === 'serial') + test.annotations.unshift({ type: 'serial', location: parentSuite.location }); if (parentSuite._locks.length) test._locks.push(...parentSuite._locks); if (inheritedRetries === undefined && parentSuite._retries !== undefined) diff --git a/tests/playwright-test/reporter-preprocess.spec.ts b/tests/playwright-test/reporter-preprocess.spec.ts index 12930c32f57d9..66a84ba4fb0fe 100644 --- a/tests/playwright-test/reporter-preprocess.spec.ts +++ b/tests/playwright-test/reporter-preprocess.spec.ts @@ -522,3 +522,41 @@ test('plan.suite temporarily exposes dependencies without changing final project 'ran keep/keep-test', ]); }); + +test('serial suites expose a serial annotation for custom sharding', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocess({ suite }) { + for (const t of suite.allTests()) + console.log('%% ' + t.title + ':' + t.annotations.filter(a => a.type === 'serial').length); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('plain', async () => {}); + test.describe.serial('s', () => { + test('serial', async () => {}); + }); + test.describe('c', () => { + test.describe.configure({ mode: 'serial' }); + test('configure', async () => {}); + }); + test.describe.serial('outer', () => { + test.describe.serial('inner', () => { + test('nested', async () => {}); + }); + }); + `, + }, { reporter: '', workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual([ + 'plain:0', + 'serial:1', + 'configure:1', + 'nested:2', + ]); +}); diff --git a/tests/playwright-test/test-modifiers.spec.ts b/tests/playwright-test/test-modifiers.spec.ts index abe3f1682c5a9..9a31b5ae72461 100644 --- a/tests/playwright-test/test-modifiers.spec.ts +++ b/tests/playwright-test/test-modifiers.spec.ts @@ -695,10 +695,11 @@ test('static modifiers should be added in serial mode', async ({ runInlineTest } expect(result.passed).toBe(0); expect(result.skipped).toBe(2); expect(result.didNotRun).toBe(1); - expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([{ type: 'slow', location: { file: expect.any(String), line: 6, column: 14 } }]); - expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([{ type: 'fixme', location: { file: expect.any(String), line: 9, column: 12 } }]); - expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([{ type: 'skip', location: { file: expect.any(String), line: 11, column: 12 } }]); - expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([]); + const serial = { type: 'serial', location: { file: expect.any(String), line: 0, column: 0 } }; + expect(result.report.suites[0].specs[0].tests[0].annotations).toEqual([serial, { type: 'slow', location: { file: expect.any(String), line: 6, column: 14 } }]); + expect(result.report.suites[0].specs[1].tests[0].annotations).toEqual([serial, { type: 'fixme', location: { file: expect.any(String), line: 9, column: 12 } }]); + expect(result.report.suites[0].specs[2].tests[0].annotations).toEqual([serial, { type: 'skip', location: { file: expect.any(String), line: 11, column: 12 } }]); + expect(result.report.suites[0].specs[3].tests[0].annotations).toEqual([serial]); }); test('should contain only one slow modifier', async ({ runInlineTest }) => { From 43a9121961cb7dacd753133a6b5765bc00289d53 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 10 Aug 2026 14:11:35 -0700 Subject: [PATCH 3/3] fix(mcp): do not run heartbeat for clients without the event stream (#42189) --- .../src/tools/utils/mcp/http.ts | 4 +- .../src/tools/utils/mcp/server.ts | 13 ++++--- tests/mcp/http.spec.ts | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/playwright-core/src/tools/utils/mcp/http.ts b/packages/playwright-core/src/tools/utils/mcp/http.ts index d6d97ab9e2460..78e75b44e196a 100644 --- a/packages/playwright-core/src/tools/utils/mcp/http.ts +++ b/packages/playwright-core/src/tools/utils/mcp/http.ts @@ -153,9 +153,7 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: async sessionId => { testDebug(`create http session`); - const sessionInfo = { transport, transportInitialized: new ManualPromise() }; - // Only give the client 5 seconds to reach for the event stream. - setTimeout(() => sessionInfo.transportInitialized.resolve(), 5000); + const sessionInfo = { transport, transportInitialized: new ManualPromise() }; sessions.set(sessionId, sessionInfo); await mcpServer.connect(serverBackendFactory, sessionInfo.transport, sessionInfo.transportInitialized, true); } diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index 252e7ccb5a20e..4799818189a9c 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -71,6 +71,7 @@ export function createServer(name: string, version: string, factory: ServerBacke }); let backendPromise: Promise | undefined; + let heartbeatStarted = false; const onClose = () => backendPromise?.then(b => b.dispose?.()).catch(serverDebug); addServerListener(server, 'close', onClose); @@ -80,12 +81,16 @@ export function createServer(name: string, version: string, factory: ServerBacke try { if (!backendPromise) { - const promise = initializeServer(server, factory, transportInitialized, runHeartbeat).then(backend => { + const promise = initializeServer(server, factory, transportInitialized).then(backend => { backend.once('disconnected', () => { if (backendPromise === promise) backendPromise = undefined; void backend.dispose?.().catch(serverDebug); }); + if (runHeartbeat && !heartbeatStarted) { + heartbeatStarted = true; + void transportInitialized.then(() => startHeartbeat(server)); + } return backend; }).catch(e => { if (backendPromise === promise) @@ -110,11 +115,11 @@ export function createServer(name: string, version: string, factory: ServerBacke return server; } -const initializeServer = async (server: ServerType, factory: ServerBackendFactory, transportInitialized: Promise, runHeartbeat: boolean): Promise => { +const initializeServer = async (server: ServerType, factory: ServerBackendFactory, transportInitialized: Promise): Promise => { const capabilities = server.getClientCapabilities(); let clientRoots: Root[] = []; if (capabilities?.roots) { - await transportInitialized; + await Promise.race([transportInitialized, new Promise(f => setTimeout(f, 5000))]); const { roots } = await server.listRoots().catch(e => { serverDebug(e); return { roots: [] }; @@ -129,8 +134,6 @@ const initializeServer = async (server: ServerType, factory: ServerBackendFactor const backend = await factory.create(clientInfo); await backend.initialize?.(clientInfo); - if (runHeartbeat) - startHeartbeat(server); return backend; }; diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 1a81fd49c266a..f24b65e28f13c 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -490,6 +490,44 @@ test('should close session when heartbeat ping is not answered', async ({ server await expect.poll(() => formatLog(stderr())['delete http session']).toBe(1); }); +test('should not reap session of a client without the event stream', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint({ env: { PLAYWRIGHT_MCP_PING_TIMEOUT_MS: '500' } }); + + // A POST-only client that never opens the GET event stream (optional per spec), + // so server-initiated pings cannot be delivered to it. + // https://github.com/microsoft/playwright-mcp/issues/1710 + const endpoint = new URL('/mcp', url); + let lastId = 0; + const post = async (body: object, sessionId?: string) => { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + ...(sessionId ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify(body), + }); + return { status: response.status, sessionId: response.headers.get('mcp-session-id'), text: await response.text() }; + }; + + const init = await post({ jsonrpc: '2.0', id: ++lastId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'post-only', version: '1.0.0' } } }); + expect(init.status).toBe(200); + const sessionId = init.sessionId!; + await post({ jsonrpc: '2.0', method: 'notifications/initialized' }, sessionId); + + const navigate = await post({ jsonrpc: '2.0', id: ++lastId, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } } }, sessionId); + expect(navigate.status).toBe(200); + + // Wait long past the ping timeout, the heartbeat must not kick in. + await new Promise(f => setTimeout(f, 1000)); + + const snapshot = await post({ jsonrpc: '2.0', id: ++lastId, method: 'tools/call', params: { name: 'browser_snapshot', arguments: {} } }, sessionId); + expect(snapshot.status).toBe(200); + expect(snapshot.text).toContain('Hello, world!'); + expect(formatLog(stderr())['delete http session']).toBeUndefined(); +}); + test('should not run heartbeat when timeout is non-positive', async ({ serverEndpoint, server }) => { const { url, stderr } = await serverEndpoint({ env: { PLAYWRIGHT_MCP_PING_TIMEOUT_MS: '0' } });