Skip to content

Commit a11f42a

Browse files
committed
chore: pass daemon session config to the server
1 parent a74302c commit a11f42a

File tree

9 files changed

+257
-227
lines changed

9 files changed

+257
-227
lines changed

packages/playwright/src/mcp/browser/config.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { firstRootPath } from '../sdk/server';
2626
import type * as playwright from '../../../types/test';
2727
import type { Config, ToolCapability } from '../config';
2828
import type { ClientInfo } from '../sdk/server';
29+
import type { SessionConfig } from '../terminal/program';
2930

3031
type ViewportSize = { width: number; height: number };
3132

@@ -42,9 +43,7 @@ export type CLIOptions = {
4243
codegen?: 'typescript' | 'none';
4344
config?: string;
4445
consoleLevel?: 'error' | 'warning' | 'info' | 'debug';
45-
daemon?: string;
46-
daemonDataDir?: string;
47-
daemonHeaded?: boolean;
46+
daemonSession?: string;
4847
device?: string;
4948
extension?: boolean;
5049
executablePath?: string;
@@ -107,22 +106,6 @@ export const defaultConfig: FullConfig = {
107106
},
108107
};
109108

110-
const defaultDaemonConfig = (cliOptions: CLIOptions) => mergeConfig(defaultConfig, {
111-
browser: {
112-
userDataDir: '<daemon-data-dir>',
113-
launchOptions: {
114-
headless: !cliOptions.daemonHeaded,
115-
},
116-
contextOptions: {
117-
viewport: cliOptions.daemonHeaded ? null : { width: 1280, height: 720 },
118-
},
119-
},
120-
outputMode: 'file',
121-
snapshot: {
122-
mode: 'full',
123-
},
124-
});
125-
126109
type BrowserUserConfig = NonNullable<Config['browser']>;
127110

128111
export type FullConfig = Config & {
@@ -146,32 +129,36 @@ export type FullConfig = Config & {
146129
navigation: number;
147130
},
148131
skillMode?: boolean;
132+
configFile?: string;
133+
sessionConfig?: SessionConfig;
149134
};
150135

151136
export async function resolveConfig(config: Config): Promise<FullConfig> {
152137
return mergeConfig(defaultConfig, config);
153138
}
154139

155140
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
156-
const configInFile = await loadConfig(cliOptions.config);
157141
const envOverrides = configFromEnv();
142+
const daemonOverrides = await configForDaemonSession(cliOptions);
158143
const cliOverrides = configFromCLIOptions(cliOptions);
159-
let result = cliOptions.daemon ? defaultDaemonConfig(cliOptions) : defaultConfig;
144+
const configFile = cliOverrides.configFile ?? envOverrides.configFile ?? daemonOverrides.configFile;
145+
const configInFile = await loadConfig(configFile);
146+
147+
let result = defaultConfig;
160148
result = mergeConfig(result, configInFile);
149+
result = mergeConfig(result, daemonOverrides);
161150
result = mergeConfig(result, envOverrides);
162151
result = mergeConfig(result, cliOverrides);
163152

164-
if (cliOptions.daemon)
153+
if (daemonOverrides.sessionConfig) {
165154
result.skillMode = true;
166155

167-
if (result.browser.userDataDir === '<daemon-data-dir>') {
168-
// No custom value provided, use the daemon data dir.
169-
const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName;
170-
const userDataDir = `${cliOptions.daemonDataDir}-${browserToken}`;
171-
172-
// Use default user profile with extension.
173-
if (!result.extension)
156+
if (!result.extension && !result.browser.userDataDir && daemonOverrides.sessionConfig.userDataDirPrefix) {
157+
// No custom value provided, use the daemon data dir.
158+
const browserToken = result.browser.launchOptions?.channel ?? result.browser?.browserName;
159+
const userDataDir = `${daemonOverrides.sessionConfig.userDataDirPrefix}-${browserToken}`;
174160
result.browser.userDataDir = userDataDir;
161+
}
175162
}
176163

177164
if (result.browser.browserName === 'chromium' && result.browser.launchOptions.chromiumSandbox === undefined) {
@@ -181,7 +168,15 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
181168
result.browser.launchOptions.chromiumSandbox = true;
182169
}
183170

171+
result.configFile = configFile;
172+
result.sessionConfig = daemonOverrides.sessionConfig;
173+
174+
// Daemon has different defaults.
175+
if (result.sessionConfig && result.browser.launchOptions.headless !== false)
176+
result.browser.contextOptions.viewport ??= { width: 1280, height: 720 };
177+
184178
await validateConfig(result);
179+
185180
return result;
186181
}
187182

@@ -202,7 +197,7 @@ async function validateConfig(config: FullConfig): Promise<void> {
202197
throw new Error('saveVideo is not supported when sharedBrowserContext is true');
203198
}
204199

205-
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
200+
export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: string } {
206201
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
207202
let channel: string | undefined;
208203
switch (cliOptions.browser) {
@@ -269,7 +264,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
269264
if (cliOptions.grantPermissions)
270265
contextOptions.permissions = cliOptions.grantPermissions;
271266

272-
const result: Config = {
267+
const config: Config = {
273268
browser: {
274269
browserName,
275270
isolated: cliOptions.isolated,
@@ -313,10 +308,10 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
313308
},
314309
};
315310

316-
return result;
311+
return { ...config, configFile: cliOptions.config };
317312
}
318313

319-
function configFromEnv(): Config {
314+
function configFromEnv(): Config & { configFile?: string } {
320315
const options: CLIOptions = {};
321316
options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTNAMES);
322317
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
@@ -364,6 +359,23 @@ function configFromEnv(): Config {
364359
return configFromCLIOptions(options);
365360
}
366361

362+
async function configForDaemonSession(cliOptions: CLIOptions): Promise<Config & { configFile?: string, sessionConfig?: SessionConfig }> {
363+
if (!cliOptions.daemonSession)
364+
return {};
365+
366+
const sessionConfig = await fs.promises.readFile(cliOptions.daemonSession, 'utf-8').then(data => JSON.parse(data) as SessionConfig);
367+
const config = configFromCLIOptions({
368+
config: sessionConfig.cli.config,
369+
browser: sessionConfig.cli.browser,
370+
isolated: sessionConfig.cli.isolated,
371+
headless: !sessionConfig.cli.headed,
372+
extension: sessionConfig.cli.extension,
373+
outputMode: 'file',
374+
snapshotMode: 'full',
375+
});
376+
return { ...config, sessionConfig };
377+
}
378+
367379
async function loadConfig(configFile: string | undefined): Promise<Config> {
368380
if (!configFile)
369381
return {};

packages/playwright/src/mcp/browser/response.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export class Response {
142142

143143
const text: string[] = [];
144144
for (const section of sections) {
145+
if (!section.content.length)
146+
continue;
145147
text.push(`### ${section.title}`);
146148
if (section.codeframe)
147149
text.push(`\`\`\`${section.codeframe}`);

packages/playwright/src/mcp/program.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,7 @@ export function decorateCommand(command: Command, version: string) {
7777
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
7878
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280x720"', resolutionParser.bind(null, '--viewport-size'))
7979
.addOption(new ProgramOption('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
80-
.addOption(new ProgramOption('--daemon <socket>', 'run as daemon').hideHelp())
81-
.addOption(new ProgramOption('--daemon-data-dir <path>', 'path to the daemon data directory.').hideHelp())
82-
.addOption(new ProgramOption('--daemon-headed', 'run daemon in headed mode').hideHelp())
83-
.addOption(new ProgramOption('--daemon-version <version>', 'version of this daemon').hideHelp())
80+
.addOption(new ProgramOption('--daemon-session <path>', 'path to the daemon config.').hideHelp())
8481
.action(async options => {
8582

8683
// normalize the --no-sandbox option: sandbox = true => nothing was passed, sandbox = false => --no-sandbox was passed.
@@ -110,15 +107,15 @@ export function decorateCommand(command: Command, version: string) {
110107
const browserContextFactory = contextFactory(config);
111108
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);
112109

113-
if (options.daemon) {
110+
if (config.sessionConfig) {
114111
const contextFactory = config.extension ? extensionContextFactory : browserContextFactory;
115112
const serverBackendFactory: mcpServer.ServerBackendFactory = {
116113
name: 'Playwright',
117114
nameInConfig: 'playwright-daemon',
118115
version,
119116
create: () => new BrowserServerBackend(config, contextFactory, { allTools: true })
120117
};
121-
const socketPath = await startMcpDaemonServer(options.daemon, serverBackendFactory, options.daemonVersion);
118+
const socketPath = await startMcpDaemonServer(config.sessionConfig, serverBackendFactory);
122119
console.error(`Daemon server listening on ${socketPath}`);
123120
return;
124121
}

packages/playwright/src/mcp/terminal/commands.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ const open = declareCommand({
2626
description: 'Open URL',
2727
category: 'core',
2828
args: z.object({
29-
url: z.string().describe('The URL to navigate to'),
29+
url: z.string().optional().describe('The URL to navigate to'),
3030
}),
3131
toolName: 'browser_navigate',
32-
toolParams: ({ url }) => ({ url }),
32+
toolParams: ({ url }) => ({ url: url || 'about:blank' }),
3333
});
3434

3535
const close = declareCommand({
@@ -498,6 +498,17 @@ const sessionList = declareCommand({
498498
toolParams: () => ({}),
499499
});
500500

501+
const sessionRestart = declareCommand({
502+
name: 'session-restart',
503+
description: 'Restart session',
504+
category: 'session',
505+
args: z.object({
506+
name: z.string().optional().describe('Name of the session to restart. If omitted, current session is restarted.'),
507+
}),
508+
toolName: '',
509+
toolParams: () => ({}),
510+
});
511+
501512
const sessionStop = declareCommand({
502513
name: 'session-stop',
503514
description: 'Stop session',
@@ -603,6 +614,7 @@ const commandsArray: AnyCommandSchema[] = [
603614
// session category
604615
sessionList,
605616
sessionStop,
617+
sessionRestart,
606618
sessionStopAll,
607619
sessionDelete,
608620
];

packages/playwright/src/mcp/terminal/daemon.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { parseCommand } from './command';
2929

3030
import type { ServerBackendFactory } from '../sdk/server';
3131
import type * as mcp from '../sdk/exports';
32+
import type { SessionConfig } from './program';
3233

3334
const daemonDebug = debug('pw:daemon');
3435

@@ -42,14 +43,11 @@ async function socketExists(socketPath: string): Promise<boolean> {
4243
return false;
4344
}
4445

45-
/**
46-
* Start a daemon server listening on Unix domain socket (Unix) or named pipe (Windows).
47-
*/
4846
export async function startMcpDaemonServer(
49-
socketPath: string,
47+
sessionConfig: SessionConfig,
5048
serverBackendFactory: ServerBackendFactory,
51-
daemonVersion: string
5249
): Promise<string> {
50+
const { socketPath, version } = sessionConfig;
5351
// Clean up existing socket file on Unix
5452
if (os.platform() !== 'win32' && await socketExists(socketPath)) {
5553
daemonDebug(`Socket already exists, removing: ${socketPath}`);
@@ -85,26 +83,10 @@ export async function startMcpDaemonServer(
8583

8684
const server = net.createServer(socket => {
8785
daemonDebug('new client connection');
88-
const connection = new SocketConnection(socket, daemonVersion);
86+
const connection = new SocketConnection(socket, version);
8987
connection.onclose = () => {
9088
daemonDebug('client disconnected');
9189
};
92-
connection.onversionerror = (id, e) => {
93-
if (daemonVersion === 'undefined-for-test')
94-
return false;
95-
96-
if (semverGreater(daemonVersion, e.received)) {
97-
// eslint-disable-next-line no-console
98-
connection.send({ id, error: `Client is too old: daemon is ${daemonVersion}, client is ${e.received}.` }).catch(e => console.error(e));
99-
} else {
100-
gracefullyProcessExitDoNotHang(0, async () => {
101-
// eslint-disable-next-line no-console
102-
await connection.send({ id, error: `Daemon is too old: daemon is ${daemonVersion}, client is ${e.received}. Stopping it.` }).catch(e => console.error(e));
103-
server.close();
104-
});
105-
}
106-
return true;
107-
};
10890
connection.onmessage = async message => {
10991
const { id, method, params } = message;
11092
try {
@@ -170,17 +152,3 @@ function parseCliCommand(args: Record<string, string> & { _: string[] }): { tool
170152
throw new Error('Command is required');
171153
return parseCommand(command, args);
172154
}
173-
174-
function semverGreater(a: string, b: string): boolean {
175-
a = a.replace(/-next$/, '');
176-
b = b.replace(/-next$/, '');
177-
const aParts = a.split('.').map(Number);
178-
const bParts = b.split('.').map(Number);
179-
for (let i = 0; i < 4; i++) {
180-
if (aParts[i] > bParts[i])
181-
return true;
182-
if (aParts[i] < bParts[i])
183-
return false;
184-
}
185-
return false;
186-
}

0 commit comments

Comments
 (0)