Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/playwright-core/src/tools/utils/mcp/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>() };
sessions.set(sessionId, sessionInfo);
await mcpServer.connect(serverBackendFactory, sessionInfo.transport, sessionInfo.transportInitialized, true);
}
Expand Down
13 changes: 8 additions & 5 deletions packages/playwright-core/src/tools/utils/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function createServer(name: string, version: string, factory: ServerBacke
});

let backendPromise: Promise<ServerBackend> | undefined;
let heartbeatStarted = false;

const onClose = () => backendPromise?.then(b => b.dispose?.()).catch(serverDebug);
addServerListener(server, 'close', onClose);
Expand All @@ -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)
Expand All @@ -110,11 +115,11 @@ export function createServer(name: string, version: string, factory: ServerBacke
return server;
}

const initializeServer = async (server: ServerType, factory: ServerBackendFactory, transportInitialized: Promise<void>, runHeartbeat: boolean): Promise<ServerBackend> => {
const initializeServer = async (server: ServerType, factory: ServerBackendFactory, transportInitialized: Promise<void>): Promise<ServerBackend> => {
const capabilities = server.getClientCapabilities();
let clientRoots: Root[] = [];
if (capabilities?.roots) {
await transportInitialized;
await Promise.race([transportInitialized, new Promise<void>(f => setTimeout(f, 5000))]);
const { roots } = await server.listRoots().catch(e => {
serverDebug(e);
return { roots: [] };
Expand All @@ -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;
};

Expand Down
2 changes: 2 additions & 0 deletions packages/playwright/src/common/suiteUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions packages/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions tests/mcp/cli-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
38 changes: 38 additions & 0 deletions tests/mcp/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } });

Expand Down
38 changes: 38 additions & 0 deletions tests/playwright-test/reporter-preprocess.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
9 changes: 5 additions & 4 deletions tests/playwright-test/test-modifiers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading