From ffe0117456ec5ac6860f2d37c44ee6fb21e1723f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Aug 2025 13:06:36 -0700 Subject: [PATCH 1/5] chore: refactor initialize (#812) --- src/browserServerBackend.ts | 15 +++++++++------ src/context.ts | 27 +++++++++++++++++++-------- src/mcp/server.ts | 23 ++++++++++++++++------- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index a8cd7c3..0a27e2a 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -43,9 +43,16 @@ export class BrowserServerBackend implements ServerBackend { this._tools = filteredTools(config); } - async initialize() { + async initialize(server: mcpServer.Server): Promise { this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config) : undefined; - this._context = new Context(this._tools, this._config, this._browserContextFactory, this._sessionLog); + this._context = new Context({ + tools: this._tools, + config: this._config, + browserContextFactory: this._browserContextFactory, + sessionLog: this._sessionLog, + clientVersion: server.getClientVersion(), + capabilities: server.getClientCapabilities() as mcpServer.ClientCapabilities, + }); } tools(): mcpServer.ToolSchema[] { @@ -69,10 +76,6 @@ export class BrowserServerBackend implements ServerBackend { return response.serialize(); } - serverInitialized(version: mcpServer.ClientVersion | undefined) { - this._context!.clientVersion = version; - } - serverClosed() { void this._context!.dispose().catch(logUnhandledError); } diff --git a/src/context.ts b/src/context.ts index 5dfc5a1..4c1018e 100644 --- a/src/context.ts +++ b/src/context.ts @@ -20,6 +20,7 @@ import * as playwright from 'playwright'; import { logUnhandledError } from './log.js'; import { Tab } from './tab.js'; +import type * as mcpServer from './mcp/server.js'; import type { Tool } from './tools/tool.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; @@ -28,6 +29,14 @@ import type { SessionLog } from './sessionLog.js'; const testDebug = debug('pw:mcp:test'); +type ContextOptions = { + tools: Tool[]; + config: FullConfig; + browserContextFactory: BrowserContextFactory; + sessionLog: SessionLog | undefined; + clientVersion: { name: string; version: string; } | undefined; + capabilities: mcpServer.ClientCapabilities | undefined; +}; export class Context { readonly tools: Tool[]; readonly config: FullConfig; @@ -36,19 +45,21 @@ export class Context { private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; - - clientVersion: { name: string; version: string; } | undefined; + private _clientVersion: { name: string; version: string; } | undefined; + private _clientCapabilities: mcpServer.ClientCapabilities; private static _allContexts: Set = new Set(); private _closeBrowserContextPromise: Promise | undefined; private _isRunningTool: boolean = false; private _abortController = new AbortController(); - constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, sessionLog: SessionLog | undefined) { - this.tools = tools; - this.config = config; - this._browserContextFactory = browserContextFactory; - this.sessionLog = sessionLog; + constructor(options: ContextOptions) { + this.tools = options.tools; + this.config = options.config; + this.sessionLog = options.sessionLog; + this._browserContextFactory = options.browserContextFactory; + this._clientVersion = options.clientVersion; + this._clientCapabilities = options.capabilities || {}; testDebug('create context'); Context._allContexts.add(this); } @@ -188,7 +199,7 @@ export class Context { if (this._closeBrowserContextPromise) throw new Error('Another browser context is being closed.'); // TODO: move to the browser context factory to make it based on isolation mode. - const result = await this._browserContextFactory.createContext(this.clientVersion!, this._abortController.signal); + const result = await this._browserContextFactory.createContext(this._clientVersion!, this._abortController.signal); const { browserContext } = result; await this._setupRequestInterception(browserContext); if (this.sessionLog) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a627022..18c3144 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -18,11 +18,18 @@ import { z } from 'zod'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { ManualPromise } from '../manualPromise.js'; +import { logUnhandledError } from '../log.js'; -import type { ImageContent, Implementation, TextContent } from '@modelcontextprotocol/sdk/types.js'; +import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +export type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -export type ClientVersion = Implementation; +export type ClientCapabilities = { + roots?: { + listRoots?: boolean + }; +}; export type ToolResponse = { content: (TextContent | ImageContent)[]; @@ -42,10 +49,9 @@ export type ToolHandler = (toolName: string, params: any) => Promise; + initialize?(server: Server): Promise; tools(): ToolSchema[]; callTool(schema: ToolSchema, parsedArguments: any): Promise; - serverInitialized?(version: ClientVersion | undefined): void; serverClosed?(): void; } @@ -53,12 +59,12 @@ export type ServerBackendFactory = () => ServerBackend; export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) { const backend = serverBackendFactory(); - await backend.initialize?.(); const server = createServer(backend, runHeartbeat); await server.connect(transport); } export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server { + const initializedPromise = new ManualPromise(); const server = new Server({ name: backend.name, version: backend.version }, { capabilities: { tools: {}, @@ -82,6 +88,8 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser let heartbeatRunning = false; server.setRequestHandler(CallToolRequestSchema, async request => { + await initializedPromise; + if (runHeartbeat && !heartbeatRunning) { heartbeatRunning = true; startHeartbeat(server); @@ -101,8 +109,9 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser return errorResult(String(error)); } }); - - addServerListener(server, 'initialized', () => backend.serverInitialized?.(server.getClientVersion())); + addServerListener(server, 'initialized', () => { + backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError); + }); addServerListener(server, 'close', () => backend.serverClosed?.()); return server; } From a60d7b8cd131ffa01ad106ad3566e8c11a20bb77 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Aug 2025 16:59:59 -0700 Subject: [PATCH 2/5] chore: slice profile dirs by root in vscode (#814) --- src/browserContextFactory.ts | 116 ++++++++++++++++++----------------- src/browserServerBackend.ts | 14 ++++- src/config.ts | 20 +++--- src/context.ts | 23 +++---- src/index.ts | 2 +- src/loopTools/context.ts | 2 +- src/program.ts | 2 +- src/sessionLog.ts | 8 +-- src/tab.ts | 3 +- src/tools/pdf.ts | 3 +- src/tools/screenshot.ts | 3 +- src/tools/utils.ts | 8 --- src/utils.ts | 29 +++++++++ tests/fixtures.ts | 25 +++++--- tests/roots.spec.ts | 66 ++++++++++++++++++++ tests/trace.spec.ts | 4 +- 16 files changed, 220 insertions(+), 108 deletions(-) create mode 100644 src/utils.ts create mode 100644 tests/roots.spec.ts diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 470ead5..5f52ec3 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -14,41 +14,44 @@ * limitations under the License. */ -import fs from 'node:fs'; -import net from 'node:net'; -import path from 'node:path'; -import os from 'node:os'; +import fs from 'fs'; +import net from 'net'; +import path from 'path'; import * as playwright from 'playwright'; - +// @ts-ignore +import { registryDirectory } from 'playwright-core/lib/server/registry/index'; import { logUnhandledError, testDebug } from './log.js'; +import { createHash } from './utils.js'; +import { outputFile } from './config.js'; import type { FullConfig } from './config.js'; -export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory { - if (browserConfig.remoteEndpoint) - return new RemoteContextFactory(browserConfig); - if (browserConfig.cdpEndpoint) - return new CdpContextFactory(browserConfig); - if (browserConfig.isolated) - return new IsolatedContextFactory(browserConfig); - return new PersistentContextFactory(browserConfig); +export function contextFactory(config: FullConfig): BrowserContextFactory { + if (config.browser.remoteEndpoint) + return new RemoteContextFactory(config); + if (config.browser.cdpEndpoint) + return new CdpContextFactory(config); + if (config.browser.isolated) + return new IsolatedContextFactory(config); + return new PersistentContextFactory(config); } -export type ClientInfo = { name: string, version: string }; +export type ClientInfo = { name?: string, version?: string, rootPath?: string }; export interface BrowserContextFactory { createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; } class BaseContextFactory implements BrowserContextFactory { - readonly browserConfig: FullConfig['browser']; + readonly config: FullConfig; protected _browserPromise: Promise | undefined; + protected _tracesDir: string | undefined; readonly name: string; - constructor(name: string, browserConfig: FullConfig['browser']) { + constructor(name: string, config: FullConfig) { this.name = name; - this.browserConfig = browserConfig; + this.config = config; } protected async _obtainBrowser(): Promise { @@ -70,7 +73,10 @@ class BaseContextFactory implements BrowserContextFactory { throw new Error('Not implemented'); } - async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + if (this.config.saveTrace) + this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`); + testDebug(`create browser context (${this.name})`); const browser = await this._obtainBrowser(); const browserContext = await this._doCreateContext(browser); @@ -94,15 +100,16 @@ class BaseContextFactory implements BrowserContextFactory { } class IsolatedContextFactory extends BaseContextFactory { - constructor(browserConfig: FullConfig['browser']) { - super('isolated', browserConfig); + constructor(config: FullConfig) { + super('isolated', config); } protected override async _doObtainBrowser(): Promise { - await injectCdpPort(this.browserConfig); - const browserType = playwright[this.browserConfig.browserName]; + await injectCdpPort(this.config.browser); + const browserType = playwright[this.config.browser.browserName]; return browserType.launch({ - ...this.browserConfig.launchOptions, + tracesDir: this._tracesDir, + ...this.config.browser.launchOptions, handleSIGINT: false, handleSIGTERM: false, }).catch(error => { @@ -113,35 +120,35 @@ class IsolatedContextFactory extends BaseContextFactory { } protected override async _doCreateContext(browser: playwright.Browser): Promise { - return browser.newContext(this.browserConfig.contextOptions); + return browser.newContext(this.config.browser.contextOptions); } } class CdpContextFactory extends BaseContextFactory { - constructor(browserConfig: FullConfig['browser']) { - super('cdp', browserConfig); + constructor(config: FullConfig) { + super('cdp', config); } protected override async _doObtainBrowser(): Promise { - return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!); + return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!); } protected override async _doCreateContext(browser: playwright.Browser): Promise { - return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; + return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0]; } } class RemoteContextFactory extends BaseContextFactory { - constructor(browserConfig: FullConfig['browser']) { - super('remote', browserConfig); + constructor(config: FullConfig) { + super('remote', config); } protected override async _doObtainBrowser(): Promise { - const url = new URL(this.browserConfig.remoteEndpoint!); - url.searchParams.set('browser', this.browserConfig.browserName); - if (this.browserConfig.launchOptions) - url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions)); - return playwright[this.browserConfig.browserName].connect(String(url)); + const url = new URL(this.config.browser.remoteEndpoint!); + url.searchParams.set('browser', this.config.browser.browserName); + if (this.config.browser.launchOptions) + url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions)); + return playwright[this.config.browser.browserName].connect(String(url)); } protected override async _doCreateContext(browser: playwright.Browser): Promise { @@ -150,27 +157,31 @@ class RemoteContextFactory extends BaseContextFactory { } class PersistentContextFactory implements BrowserContextFactory { - readonly browserConfig: FullConfig['browser']; + readonly config: FullConfig; private _userDataDirs = new Set(); - constructor(browserConfig: FullConfig['browser']) { - this.browserConfig = browserConfig; + constructor(config: FullConfig) { + this.config = config; } - async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { - await injectCdpPort(this.browserConfig); + async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + await injectCdpPort(this.config.browser); testDebug('create browser context (persistent)'); - const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(); + const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath); + let tracesDir: string | undefined; + if (this.config.saveTrace) + tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`); this._userDataDirs.add(userDataDir); testDebug('lock user data dir', userDataDir); - const browserType = playwright[this.browserConfig.browserName]; + const browserType = playwright[this.config.browser.browserName]; for (let i = 0; i < 5; i++) { try { const browserContext = await browserType.launchPersistentContext(userDataDir, { - ...this.browserConfig.launchOptions, - ...this.browserConfig.contextOptions, + tracesDir, + ...this.config.browser.launchOptions, + ...this.config.browser.contextOptions, handleSIGINT: false, handleSIGTERM: false, }); @@ -198,17 +209,12 @@ class PersistentContextFactory implements BrowserContextFactory { testDebug('close browser context complete (persistent)'); } - private async _createUserDataDir() { - let cacheDirectory: string; - if (process.platform === 'linux') - cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); - else if (process.platform === 'darwin') - cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); - else if (process.platform === 'win32') - cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - else - throw new Error('Unsupported platform: ' + process.platform); - const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`); + private async _createUserDataDir(rootPath: string | undefined) { + const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory; + const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName; + // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead. + const rootPathToken = rootPath ? `-${createHash(rootPath)}` : ''; + const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`); await fs.promises.mkdir(result, { recursive: true }); return result; } diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index 0a27e2a..bdf0173 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { fileURLToPath } from 'url'; import { FullConfig } from './config.js'; import { Context } from './context.js'; import { logUnhandledError } from './log.js'; @@ -44,14 +45,21 @@ export class BrowserServerBackend implements ServerBackend { } async initialize(server: mcpServer.Server): Promise { - this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config) : undefined; + const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities; + let rootPath: string | undefined; + if (capabilities.roots) { + const { roots } = await server.listRoots(); + const firstRootUri = roots[0]?.uri; + const url = firstRootUri ? new URL(firstRootUri) : undefined; + rootPath = url ? fileURLToPath(url) : undefined; + } + this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined; this._context = new Context({ tools: this._tools, config: this._config, browserContextFactory: this._browserContextFactory, sessionLog: this._sessionLog, - clientVersion: server.getClientVersion(), - capabilities: server.getClientCapabilities() as mcpServer.ClientCapabilities, + clientInfo: { ...server.getClientVersion(), rootPath }, }); } diff --git a/src/config.ts b/src/config.ts index 4ed58c9..2dddac5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { devices } from 'playwright'; -import { sanitizeForFilePath } from './tools/utils.js'; +import { sanitizeForFilePath } from './utils.js'; + import type { Config, ToolCapability } from '../config.js'; import type { BrowserContextOptions, LaunchOptions } from 'playwright'; @@ -67,7 +68,7 @@ const defaultConfig: FullConfig = { blockedOrigins: undefined, }, server: {}, - outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())), + saveTrace: false, }; type BrowserUserConfig = NonNullable; @@ -79,7 +80,7 @@ export type FullConfig = Config & { contextOptions: NonNullable; }, network: NonNullable, - outputDir: string; + saveTrace: boolean; server: NonNullable, }; @@ -95,9 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { } } -export async function outputFile(config: FullConfig, name: string): Promise { - await fs.promises.mkdir(config.outputDir, { recursive: true }); +export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise { + const outputDir = config.outputDir + ?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined) + ?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())); + + await fs.promises.mkdir(outputDir, { recursive: true }); const fileName = sanitizeForFilePath(name); - return path.join(config.outputDir, fileName); + return path.join(outputDir, fileName); } function pickDefined(obj: T | undefined): Partial { diff --git a/src/context.ts b/src/context.ts index 4c1018e..9b5dc49 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,11 +19,11 @@ import * as playwright from 'playwright'; import { logUnhandledError } from './log.js'; import { Tab } from './tab.js'; +import { outputFile } from './config.js'; -import type * as mcpServer from './mcp/server.js'; -import type { Tool } from './tools/tool.js'; import type { FullConfig } from './config.js'; -import type { BrowserContextFactory } from './browserContextFactory.js'; +import type { Tool } from './tools/tool.js'; +import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js'; import type * as actions from './actions.js'; import type { SessionLog } from './sessionLog.js'; @@ -34,9 +34,9 @@ type ContextOptions = { config: FullConfig; browserContextFactory: BrowserContextFactory; sessionLog: SessionLog | undefined; - clientVersion: { name: string; version: string; } | undefined; - capabilities: mcpServer.ClientCapabilities | undefined; + clientInfo: ClientInfo; }; + export class Context { readonly tools: Tool[]; readonly config: FullConfig; @@ -45,8 +45,7 @@ export class Context { private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; - private _clientVersion: { name: string; version: string; } | undefined; - private _clientCapabilities: mcpServer.ClientCapabilities; + private _clientInfo: ClientInfo; private static _allContexts: Set = new Set(); private _closeBrowserContextPromise: Promise | undefined; @@ -58,8 +57,7 @@ export class Context { this.config = options.config; this.sessionLog = options.sessionLog; this._browserContextFactory = options.browserContextFactory; - this._clientVersion = options.clientVersion; - this._clientCapabilities = options.capabilities || {}; + this._clientInfo = options.clientInfo; testDebug('create context'); Context._allContexts.add(this); } @@ -105,7 +103,6 @@ export class Context { return this._currentTab!; } - async closeTab(index: number | undefined): Promise { const tab = index === undefined ? this._currentTab : this._tabs[index]; if (!tab) @@ -115,6 +112,10 @@ export class Context { return url; } + async outputFile(name: string): Promise { + return outputFile(this.config, this._clientInfo.rootPath, name); + } + private _onPageCreated(page: playwright.Page) { const tab = new Tab(this, page, tab => this._onPageClosed(tab)); this._tabs.push(tab); @@ -199,7 +200,7 @@ export class Context { if (this._closeBrowserContextPromise) throw new Error('Another browser context is being closed.'); // TODO: move to the browser context factory to make it based on isolation mode. - const result = await this._browserContextFactory.createContext(this._clientVersion!, this._abortController.signal); + const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal); const { browserContext } = result; await this._setupRequestInterception(browserContext); if (this.sessionLog) diff --git a/src/index.ts b/src/index.ts index 88c95f8..d180b7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { const config = await resolveConfig(userConfig); - const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser); + const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); return mcpServer.createServer(new BrowserServerBackend(config, factory), false); } diff --git a/src/loopTools/context.ts b/src/loopTools/context.ts index b1b4709..732af07 100644 --- a/src/loopTools/context.ts +++ b/src/loopTools/context.ts @@ -45,7 +45,7 @@ export class Context { static async create(config: FullConfig) { const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' }); - const browserContextFactory = contextFactory(config.browser); + const browserContextFactory = contextFactory(config); const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false); await client.connect(new InProcessTransport(server)); await client.ping(); diff --git a/src/program.ts b/src/program.ts index f34c57e..51eb116 100644 --- a/src/program.ts +++ b/src/program.ts @@ -77,7 +77,7 @@ program return; } - const browserContextFactory = contextFactory(config.browser); + const browserContextFactory = contextFactory(config); const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory); await mcpTransport.start(serverBackendFactory, config.server); diff --git a/src/sessionLog.ts b/src/sessionLog.ts index 7d6a365..ba841b4 100644 --- a/src/sessionLog.ts +++ b/src/sessionLog.ts @@ -17,10 +17,10 @@ import fs from 'fs'; import path from 'path'; -import { outputFile } from './config.js'; import { Response } from './response.js'; - import { logUnhandledError } from './log.js'; +import { outputFile } from './config.js'; + import type { FullConfig } from './config.js'; import type * as actions from './actions.js'; import type { Tab, TabSnapshot } from './tab.js'; @@ -51,8 +51,8 @@ export class SessionLog { this._file = path.join(this._folder, 'session.md'); } - static async create(config: FullConfig): Promise { - const sessionFolder = await outputFile(config, `session-${Date.now()}`); + static async create(config: FullConfig, rootPath: string | undefined): Promise { + const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`); await fs.promises.mkdir(sessionFolder, { recursive: true }); // eslint-disable-next-line no-console console.error(`Session: ${sessionFolder}`); diff --git a/src/tab.ts b/src/tab.ts index 2e14907..d7f44fa 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -20,7 +20,6 @@ import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { logUnhandledError } from './log.js'; import { ManualPromise } from './manualPromise.js'; import { ModalState } from './tools/tool.js'; -import { outputFile } from './config.js'; import type { Context } from './context.js'; @@ -115,7 +114,7 @@ export class Tab extends EventEmitter { const entry = { download, finished: false, - outputFile: await outputFile(this.context.config, download.suggestedFilename()) + outputFile: await this.context.outputFile(download.suggestedFilename()) }; this._downloads.push(entry); await download.saveAs(entry.outputFile); diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index 5a10ae5..3de0092 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -18,7 +18,6 @@ import { z } from 'zod'; import { defineTabTool } from './tool.js'; import * as javascript from '../javascript.js'; -import { outputFile } from '../config.js'; const pdfSchema = z.object({ filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'), @@ -36,7 +35,7 @@ const pdf = defineTabTool({ }, handle: async (tab, params, response) => { - const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); + const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`); response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`); response.addResult(`Saved page as ${fileName}`); await tab.page.pdf({ path: fileName }); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 55a8107..d508f21 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -18,7 +18,6 @@ import { z } from 'zod'; import { defineTabTool } from './tool.js'; import * as javascript from '../javascript.js'; -import { outputFile } from '../config.js'; import { generateLocator } from './utils.js'; import type * as playwright from 'playwright'; @@ -53,7 +52,7 @@ const screenshot = defineTabTool({ handle: async (tab, params, response) => { const fileType = params.type || 'png'; - const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); + const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`); const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 90, diff --git a/src/tools/utils.ts b/src/tools/utils.ts index dfacef2..e25aec9 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -71,14 +71,6 @@ export async function waitForCompletion(tab: Tab, callback: () => Promise) } } -export function sanitizeForFilePath(s: string) { - const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); - const separator = s.lastIndexOf('.'); - if (separator === -1) - return sanitize(s); - return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1)); -} - export async function generateLocator(locator: playwright.Locator): Promise { try { const { resolvedSelector } = await (locator as any)._resolveSelector(); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..6d1feb0 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'crypto'; + +export function createHash(data: string): string { + return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7); +} + +export function sanitizeForFilePath(s: string) { + const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); + const separator = s.lastIndexOf('.'); + if (separator === -1) + return sanitize(s); + return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1)); +} diff --git a/tests/fixtures.ts b/tests/fixtures.ts index b262ad5..38b78ca 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -22,6 +22,7 @@ import { chromium } from 'playwright'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { TestServer } from './testserver/index.ts'; import type { Config } from '../config'; @@ -41,7 +42,12 @@ type CDPServer = { type TestFixtures = { client: Client; - startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>; + startClient: (options?: { + clientName?: string, + args?: string[], + config?: Config, + roots?: { name: string, uri: string }[], + }) => Promise<{ client: Client, stderr: () => string }>; wsEndpoint: string; cdpServer: CDPServer; server: TestServer; @@ -61,14 +67,11 @@ export const test = baseTest.extend( }, startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { - const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const configDir = path.dirname(test.info().config.configFile!); let client: Client | undefined; await use(async options => { const args: string[] = []; - if (userDataDir) - args.push('--user-data-dir', userDataDir); if (process.env.CI && process.platform === 'linux') args.push('--no-sandbox'); if (mcpHeadless) @@ -83,8 +86,15 @@ export const test = baseTest.extend( args.push(`--config=${path.relative(configDir, configFile)}`); } - client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); - const { transport, stderr } = await createTransport(args, mcpMode); + client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined); + if (options?.roots) { + client.setRequestHandler(ListRootsRequestSchema, async request => { + return { + roots: options.roots, + }; + }); + } + const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright')); let stderrBuffer = ''; stderr?.on('data', data => { if (process.env.PWMCP_DEBUG) @@ -160,7 +170,7 @@ export const test = baseTest.extend( }, }); -async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ +async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{ transport: Transport, stderr: Stream | null, }> { @@ -188,6 +198,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): DEBUG: 'pw:mcp:test', DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', + PWMCP_PROFILES_DIR_FOR_TEST: profilesDir, }, }); return { diff --git a/tests/roots.spec.ts b/tests/roots.spec.ts new file mode 100644 index 0000000..4c7aa0e --- /dev/null +++ b/tests/roots.spec.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; + +import { test, expect } from './fixtures.js'; +import { createHash } from '../src/utils.js'; + +test('should use separate user data by root path', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + roots: [ + { + name: 'test', + uri: 'file:///non/existent/folder', + }, + ], + }); + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const hash = createHash('/non/existent/folder'); + const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright')); + expect(file).toContain(hash); +}); + + +test('check that trace is saved in workspace', async ({ startClient, server, mcpMode }, testInfo) => { + const rootPath = testInfo.outputPath('workspace'); + const { client } = await startClient({ + args: ['--save-trace'], + roots: [ + { + name: 'workspace', + uri: pathToFileURL(rootPath).toString(), + }, + ], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); + + const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp')); + expect(file).toContain('traces'); +}); diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts index 5cfe3e0..6cfe12e 100644 --- a/tests/trace.spec.ts +++ b/tests/trace.spec.ts @@ -15,7 +15,6 @@ */ import fs from 'fs'; -import path from 'path'; import { test, expect } from './fixtures.js'; @@ -33,5 +32,6 @@ test('check that trace is saved', async ({ startClient, server, mcpMode }, testI code: expect.stringContaining(`page.goto('http://localhost`), }); - expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); + const [file] = await fs.promises.readdir(outputDir); + expect(file).toContain('traces'); }); From 372395666a0e6f895c8f5ad97ff56738dd3221d6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 1 Aug 2025 17:34:28 -0700 Subject: [PATCH 3/5] chore: allow to switch between browser connection methods (#815) --- src/browserContextFactory.ts | 17 ++++--- src/browserServerBackend.ts | 56 ++++++++++++++++++++++-- src/context.ts | 2 + src/extension/extensionContextFactory.ts | 3 ++ src/extension/main.ts | 6 ++- src/index.ts | 5 ++- src/loopTools/context.ts | 2 +- src/program.ts | 10 +++-- 8 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 5f52ec3..2d3fcff 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -40,17 +40,21 @@ export function contextFactory(config: FullConfig): BrowserContextFactory { export type ClientInfo = { name?: string, version?: string, rootPath?: string }; export interface BrowserContextFactory { + readonly name: string; + readonly description: string; createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; } class BaseContextFactory implements BrowserContextFactory { + readonly name: string; + readonly description: string; readonly config: FullConfig; protected _browserPromise: Promise | undefined; protected _tracesDir: string | undefined; - readonly name: string; - constructor(name: string, config: FullConfig) { + constructor(name: string, description: string, config: FullConfig) { this.name = name; + this.description = description; this.config = config; } @@ -101,7 +105,7 @@ class BaseContextFactory implements BrowserContextFactory { class IsolatedContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('isolated', config); + super('isolated', 'Create a new isolated browser context', config); } protected override async _doObtainBrowser(): Promise { @@ -126,7 +130,7 @@ class IsolatedContextFactory extends BaseContextFactory { class CdpContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('cdp', config); + super('cdp', 'Connect to a browser over CDP', config); } protected override async _doObtainBrowser(): Promise { @@ -140,7 +144,7 @@ class CdpContextFactory extends BaseContextFactory { class RemoteContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('remote', config); + super('remote', 'Connect to a browser using a remote endpoint', config); } protected override async _doObtainBrowser(): Promise { @@ -158,6 +162,9 @@ class RemoteContextFactory extends BaseContextFactory { class PersistentContextFactory implements BrowserContextFactory { readonly config: FullConfig; + readonly name = 'persistent'; + readonly description = 'Create a new persistent browser context'; + private _userDataDirs = new Set(); constructor(config: FullConfig) { diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index bdf0173..95b60e3 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -15,6 +15,7 @@ */ import { fileURLToPath } from 'url'; +import { z } from 'zod'; import { FullConfig } from './config.js'; import { Context } from './context.js'; import { logUnhandledError } from './log.js'; @@ -22,11 +23,16 @@ import { Response } from './response.js'; import { SessionLog } from './sessionLog.js'; import { filteredTools } from './tools.js'; import { packageJSON } from './package.js'; +import { defineTool } from './tools/tool.js'; +import type { Tool } from './tools/tool.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type * as mcpServer from './mcp/server.js'; import type { ServerBackend } from './mcp/server.js'; -import type { Tool } from './tools/tool.js'; + +type NonEmptyArray = [T, ...T[]]; + +export type FactoryList = NonEmptyArray; export class BrowserServerBackend implements ServerBackend { name = 'Playwright'; @@ -38,10 +44,12 @@ export class BrowserServerBackend implements ServerBackend { private _config: FullConfig; private _browserContextFactory: BrowserContextFactory; - constructor(config: FullConfig, browserContextFactory: BrowserContextFactory) { + constructor(config: FullConfig, factories: FactoryList) { this._config = config; - this._browserContextFactory = browserContextFactory; + this._browserContextFactory = factories[0]; this._tools = filteredTools(config); + if (factories.length > 1) + this._tools.push(this._defineContextSwitchTool(factories)); } async initialize(server: mcpServer.Server): Promise { @@ -87,4 +95,46 @@ export class BrowserServerBackend implements ServerBackend { serverClosed() { void this._context!.dispose().catch(logUnhandledError); } + + private _defineContextSwitchTool(factories: FactoryList): Tool { + const self = this; + return defineTool({ + capability: 'core', + + schema: { + name: 'browser_connect', + title: 'Connect to a browser context', + description: [ + 'Connect to a browser using one of the available methods:', + ...factories.map(factory => `- "${factory.name}": ${factory.description}`), + ].join('\n'), + inputSchema: z.object({ + method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'), + }), + type: 'readOnly', + }, + + async handle(context, params, response) { + const factory = factories.find(factory => factory.name === params.method); + if (!factory) { + response.addError('Unknown connection method: ' + params.method); + return; + } + await self._setContextFactory(factory); + response.addResult('Successfully changed connection method.'); + } + }); + } + + private async _setContextFactory(newFactory: BrowserContextFactory) { + if (this._context) { + const options = { + ...this._context.options, + browserContextFactory: newFactory, + }; + await this._context.dispose(); + this._context = new Context(options); + } + this._browserContextFactory = newFactory; + } } diff --git a/src/context.ts b/src/context.ts index 9b5dc49..e84356d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -41,6 +41,7 @@ export class Context { readonly tools: Tool[]; readonly config: FullConfig; readonly sessionLog: SessionLog | undefined; + readonly options: ContextOptions; private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; @@ -56,6 +57,7 @@ export class Context { this.tools = options.tools; this.config = options.config; this.sessionLog = options.sessionLog; + this.options = options; this._browserContextFactory = options.browserContextFactory; this._clientInfo = options.clientInfo; testDebug('create context'); diff --git a/src/extension/extensionContextFactory.ts b/src/extension/extensionContextFactory.ts index bb23605..da04d44 100644 --- a/src/extension/extensionContextFactory.ts +++ b/src/extension/extensionContextFactory.ts @@ -24,6 +24,9 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory const debugLogger = debug('pw:mcp:relay'); export class ExtensionContextFactory implements BrowserContextFactory { + name = 'extension'; + description = 'Connect to a browser using the Playwright MCP extension'; + private _browserChannel: string; private _relayPromise: Promise | undefined; private _browserPromise: Promise | undefined; diff --git a/src/extension/main.ts b/src/extension/main.ts index 50c2e3a..bb3a197 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -22,6 +22,10 @@ import type { FullConfig } from '../config.js'; export async function runWithExtension(config: FullConfig) { const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome'); - const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory); + const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]); await mcpTransport.start(serverBackendFactory, config.server); } + +export function createExtensionContextFactory(config: FullConfig) { + return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome'); +} diff --git a/src/index.ts b/src/index.ts index d180b7f..2d181b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,10 +27,13 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { const config = await resolveConfig(userConfig); const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); - return mcpServer.createServer(new BrowserServerBackend(config, factory), false); + return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false); } class SimpleBrowserContextFactory implements BrowserContextFactory { + name = 'custom'; + description = 'Connect to a browser using a custom context getter'; + private readonly _contextGetter: () => Promise; constructor(contextGetter: () => Promise) { diff --git a/src/loopTools/context.ts b/src/loopTools/context.ts index 732af07..9e52577 100644 --- a/src/loopTools/context.ts +++ b/src/loopTools/context.ts @@ -46,7 +46,7 @@ export class Context { static async create(config: FullConfig) { const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' }); const browserContextFactory = contextFactory(config); - const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false); + const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false); await client.connect(new InProcessTransport(server)); await client.ping(); return new Context(config, client); diff --git a/src/program.ts b/src/program.ts index 51eb116..ae1f1d9 100644 --- a/src/program.ts +++ b/src/program.ts @@ -21,8 +21,8 @@ import { startTraceViewerServer } from 'playwright-core/lib/server'; import * as mcpTransport from './mcp/transport.js'; import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { packageJSON } from './package.js'; -import { runWithExtension } from './extension/main.js'; -import { BrowserServerBackend } from './browserServerBackend.js'; +import { createExtensionContextFactory, runWithExtension } from './extension/main.js'; +import { BrowserServerBackend, FactoryList } from './browserServerBackend.js'; import { Context } from './context.js'; import { contextFactory } from './browserContextFactory.js'; import { runLoopTools } from './loopTools/main.js'; @@ -56,6 +56,7 @@ program .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') .addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp()) + .addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp()) .addOption(new Option('--loop-tools', 'Run loop tools').hideHelp()) .addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .action(async options => { @@ -78,7 +79,10 @@ program } const browserContextFactory = contextFactory(config); - const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory); + const factories: FactoryList = [browserContextFactory]; + if (options.connectTool) + factories.push(createExtensionContextFactory(config)); + const serverBackendFactory = () => new BrowserServerBackend(config, factories); await mcpTransport.start(serverBackendFactory, config.server); if (config.saveTrace) { From 41a44f7abc2aac10f3846e11998b1054c9810555 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 1 Aug 2025 17:56:47 -0700 Subject: [PATCH 4/5] chore(extension): terminate connection on debugger detach (#816) --- extension/src/relayConnection.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/extension/src/relayConnection.ts b/extension/src/relayConnection.ts index 2913571..a7fd4f7 100644 --- a/extension/src/relayConnection.ts +++ b/extension/src/relayConnection.ts @@ -59,10 +59,6 @@ export class RelayConnection { this._ws.close(1000, message); } - private async _detachDebugger(): Promise { - await chrome.debugger.detach(this._debuggee); - } - private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { if (source.tabId !== this._debuggee.tabId) return; @@ -81,13 +77,7 @@ export class RelayConnection { private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { if (source.tabId !== this._debuggee.tabId) return; - this._sendMessage({ - method: 'detachedFromTab', - params: { - tabId: this._debuggee.tabId, - reason, - }, - }); + this.close(`Debugger detached: ${reason}`); this._debuggee = { }; } @@ -131,10 +121,6 @@ export class RelayConnection { targetInfo: result?.targetInfo, }; } - if (message.method === 'detachFromTab') { - debugLog('Detaching debugger from tab:', this._debuggee); - return await this._detachDebugger(); - } if (message.method === 'forwardCDPCommand') { const { sessionId, method, params } = message.params; debugLog('CDP command:', method, params); From 3c6eac9b218c6ebf354052f5a94878a0768d32a8 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 1 Aug 2025 18:19:03 -0700 Subject: [PATCH 5/5] chore: follow up with win test fix (#818) --- tests/roots.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/roots.spec.ts b/tests/roots.spec.ts index 4c7aa0e..9c8bb01 100644 --- a/tests/roots.spec.ts +++ b/tests/roots.spec.ts @@ -21,13 +21,15 @@ import { pathToFileURL } from 'url'; import { test, expect } from './fixtures.js'; import { createHash } from '../src/utils.js'; +const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder'; + test('should use separate user data by root path', async ({ startClient, server }, testInfo) => { const { client } = await startClient({ roots: [ { name: 'test', - uri: 'file:///non/existent/folder', - }, + uri: 'file://' + p.replace(/\\/g, '/'), + } ], }); @@ -36,7 +38,7 @@ test('should use separate user data by root path', async ({ startClient, server arguments: { url: server.HELLO_WORLD }, }); - const hash = createHash('/non/existent/folder'); + const hash = createHash(p); const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright')); expect(file).toContain(hash); });