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
62 changes: 39 additions & 23 deletions packages/playwright-core/src/cli/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ import path from 'path';

import { calculateSha1 } from '../../utils';
import { debug } from '../../utilsBundle';

import { decorateServer } from '../../server/utils/network';
import { gracefullyProcessExitDoNotHang } from '../../server/utils/processLauncher';

import { BrowserServerBackend } from '../../tools/browserServerBackend';
import { browserTools } from '../../tools/tools';
import { SocketConnection } from '../client/socketConnection';
import { commands } from './commands';
import { parseCommand } from './command';
import { commands } from './commands';

import { SocketConnection } from '../client/socketConnection';
import { createClientInfo } from '../client/registry';

import type * as playwright from '../../..';
import type * as tools from '../../tools/exports';
import type * as mcp from '../../mcp/exports';
import type { SessionConfig, ClientInfo } from '../client/registry';
import type { BrowserContext } from '../../client/browserContext';

Expand All @@ -49,14 +50,17 @@ async function socketExists(socketPath: string): Promise<boolean> {
return false;
}

export async function startMcpDaemonServer(
export async function startCliDaemonServer(
sessionName: string,
browserContext: playwright.BrowserContext,
mcpConfig: tools.ContextConfig,
contextConfig: tools.ContextConfig = {},
clientInfo = createClientInfo(),
persistent?: boolean,
options?: {
persistent?: boolean,
exitOnClose?: boolean,
}
): Promise<string> {
const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, persistent);
const sessionConfig = createSessionConfig(clientInfo, sessionName, browserContext, options);
const { socketPath } = sessionConfig;

// Clean up existing socket file on Unix
Expand All @@ -70,11 +74,14 @@ export async function startMcpDaemonServer(
}
}

const backend = new BrowserServerBackend(mcpConfig, browserContext, browserTools);
const backend = new BrowserServerBackend(contextConfig, browserContext, browserTools);
await backend.initialize({ cwd: process.cwd() });

await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });

if ((browserContext as BrowserContext)._closingStatus !== 'none')
throw new Error('Browser context was closed before the daemon could start');

const server = net.createServer(socket => {
daemonDebug('new client connection');
const connection = new SocketConnection(socket);
Expand All @@ -87,15 +94,12 @@ export async function startMcpDaemonServer(
daemonDebug('received command', method);
if (method === 'stop') {
daemonDebug('stop command received, shutting down');
if (process.platform !== 'win32')
await fs.promises.unlink(sessionConfig.socketPath).catch(() => {});
if (!sessionConfig.cli.persistent)
await deleteSessionFile(clientInfo, sessionConfig);

gracefullyProcessExitDoNotHang(0, async () => {
await connection.send({ id, result: 'ok' }).catch(() => {});
server.close();
});
await deleteSessionFile(clientInfo, sessionConfig);
const sendAck = async () => connection.send({ id, result: 'ok' }).catch(() => {});
if (options?.exitOnClose)
gracefullyProcessExitDoNotHang(0, () => sendAck());
else
await sendAck();
} else if (method === 'run') {
const { toolName, toolParams } = parseCliCommand(params.args);
if (params.cwd)
Expand All @@ -112,7 +116,13 @@ export async function startMcpDaemonServer(
}
};
});

decorateServer(server);
browserContext.on('close', () => Promise.resolve().then(async () => {
await deleteSessionFile(clientInfo, sessionConfig);
if (options?.exitOnClose)
gracefullyProcessExitDoNotHang(0);
}));

await new Promise<void>((resolve, reject) => {
server.on('error', (error: NodeJS.ErrnoException) => {
Expand All @@ -133,17 +143,20 @@ async function saveSessionFile(clientInfo: ClientInfo, sessionConfig: SessionCon
}

async function deleteSessionFile(clientInfo: ClientInfo, sessionConfig: SessionConfig) {
const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`);
await fs.promises.rm(sessionFile).catch(() => {});
await fs.promises.unlink(sessionConfig.socketPath).catch(() => {});
if (!sessionConfig.cli.persistent) {
const sessionFile = path.join(clientInfo.daemonProfilesDir, `${sessionConfig.name}.session`);
await fs.promises.rm(sessionFile).catch(() => {});
}
}

function formatResult(result: mcp.CallToolResult) {
function formatResult(result: tools.CallToolResult) {
const isError = result.isError;
const text = result.content[0].type === 'text' ? result.content[0].text : undefined;
return { isError, text };
}

function parseCliCommand(args: Record<string, string> & { _: string[] }): { toolName: string, toolParams: NonNullable<mcp.CallToolRequest['params']['arguments']> } {
function parseCliCommand(args: Record<string, string> & { _: string[] }): { toolName: string, toolParams: NonNullable<tools.CallToolRequest['params']['arguments']> } {
const command = commands[args._[0]];
if (!command)
throw new Error('Command is required');
Expand All @@ -159,15 +172,18 @@ function daemonSocketPath(clientInfo: ClientInfo, sessionName: string): string {
return path.join(socketsDir, clientInfo.workspaceDirHash, socketName);
}

function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, persistent?: boolean): SessionConfig {
function createSessionConfig(clientInfo: ClientInfo, sessionName: string, browserContext: playwright.BrowserContext, options: {
persistent?: boolean,
exitOnStop?: boolean,
} = {}): SessionConfig {
const bc = browserContext as BrowserContext;
return {
name: sessionName,
version: clientInfo.version,
timestamp: Date.now(),
socketPath: daemonSocketPath(clientInfo, sessionName),
workspaceDir: clientInfo.workspaceDir,
cli: { persistent },
cli: { persistent: options.persistent },
browser: {
browserName: bc.browser()!.browserType().name(),
launchOptions: bc.browser()!._options,
Expand Down
6 changes: 2 additions & 4 deletions packages/playwright-core/src/cli/daemon/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@
import fs from 'fs';
import path from 'path';

import { startMcpDaemonServer } from './daemon';
import { startCliDaemonServer } from './daemon';
import { setupExitWatchdog } from '../../mcp/watchdog';
import { contextFactory } from '../../mcp/browserContextFactory';
import { ExtensionContextFactory } from '../../mcp/extensionContextFactory';
import * as configUtils from '../../mcp/config';
import { gracefullyProcessExitDoNotHang } from '../../utils';
import { ClientInfo, createClientInfo } from '../client/registry';

import type { Command } from '../../utilsBundle';
Expand Down Expand Up @@ -52,8 +51,7 @@ export function decorateCLICommand(command: Command, version: string) {
const browserContextFactory = contextFactory(mcpConfig);
const cf = mcpConfig.extension ? extensionContextFactory : browserContextFactory;
const browserContext = mcpConfig.browser.isolated ? await cf.createContext(mcpClientInfo) : (await cf.contexts(mcpClientInfo))[0];
browserContext.on('close', () => gracefullyProcessExitDoNotHang(0));
const socketPath = await startMcpDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, options.persistent);
const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true });
console.log(`### Success\nDaemon listening on ${socketPath}`);
console.log('<EOF>');
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/mcp/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
export { createClientInfo } from '../cli/client/registry';
export { logUnhandledError } from './log';
export { setupExitWatchdog } from './watchdog';
export { startMcpDaemonServer } from '../cli/daemon/daemon';
export { startCliDaemonServer } from '../cli/daemon/daemon';
export * from './sdk/server';
export * from './sdk/tool';

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ export { parseResponse } from './response';
export { Tab } from './tab';

export type { ContextConfig } from './context';
export type { Tool as BrowserTool } from './tool';
export type { Tool, CallToolResult, CallToolRequest } from './tool';
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type * as playwright from '../../types/types';
import type { Tab } from './tab';
import type { Response } from './response';

export type { CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';

type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/test/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export async function runDaemonForContext(testInfo: TestInfoImpl, context: playw

const outputDir = path.join(testInfo.artifactsDir(), '.playwright-mcp');
const sessionName = `test-worker-${createGuid().slice(0, 6)}`;
await mcp.startMcpDaemonServer(sessionName, context, {
await mcp.startCliDaemonServer(sessionName, context, {
outputMode: 'file',
snapshot: { mode: 'full' },
outputDir,
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright/src/mcp/test/testBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import * as generatorTools from './generatorTools.js';
import * as plannerTools from './plannerTools.js';

import type { TestTool } from './testTool';
import type { BrowserTool } from 'playwright-core/lib/tools/exports';
import type { Tool } from 'playwright-core/lib/tools/exports';

const typesWithIntent = ['action', 'assertion', 'input'];

Expand Down Expand Up @@ -76,7 +76,7 @@ export class TestServerBackend extends EventEmitter implements mcp.ServerBackend
}
}

function wrapBrowserTool(tool: BrowserTool): TestTool {
function wrapBrowserTool(tool: Tool): TestTool {
const inputSchema = typesWithIntent.includes(tool.schema.type) ? (tool.schema.inputSchema as any).extend({
intent: zod.string().describe('The intent of the call, for example the test step description plan idea')
}) : tool.schema.inputSchema;
Expand Down
Loading