diff --git a/README.md b/README.md index ee245ed..899891d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,9 @@ npx @playwright/mcp@latest --config path/to/config.json // Browser type to use (chromium, firefox, or webkit) browserName?: 'chromium' | 'firefox' | 'webkit'; + // Keep the browser profile in memory, do not save it to disk. + ephemeral?: boolean; + // Path to user data directory for browser profile persistence userDataDir?: string; diff --git a/config.d.ts b/config.d.ts index 587b657..6a243eb 100644 --- a/config.d.ts +++ b/config.d.ts @@ -28,6 +28,11 @@ export type Config = { */ browserName?: 'chromium' | 'firefox' | 'webkit'; + /** + * Keep the browser profile in memory, do not save it to disk. + */ + ephemeral?: boolean; + /** * Path to a user data directory for browser profile persistence. * Temporary directory is created by default. diff --git a/src/config.ts b/src/config.ts index 5b5f471..12399e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,7 @@ export type CLIOptions = { browser?: string; caps?: string; cdpEndpoint?: string; + ephemeral?: boolean; executablePath?: string; headless?: boolean; device?: string; @@ -106,6 +107,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise; }; +type BrowserContextAndBrowser = { + browser?: playwright.Browser; + browserContext: playwright.BrowserContext; +}; + export class Context { readonly tools: Tool[]; readonly config: Config; - private _browser: playwright.Browser | undefined; - private _browserContext: playwright.BrowserContext | undefined; - private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined; + private _browserContextPromise: Promise | undefined; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; private _modalStates: (ModalState & { tab: Tab })[] = []; @@ -85,7 +88,7 @@ export class Context { } async newTab(): Promise { - const browserContext = await this._ensureBrowserContext(); + const { browserContext } = await this._ensureBrowserContext(); const page = await browserContext.newPage(); this._currentTab = this._tabs.find(t => t.page === page)!; return this._currentTab; @@ -97,9 +100,9 @@ export class Context { } async ensureTab(): Promise { - const context = await this._ensureBrowserContext(); + const { browserContext } = await this._ensureBrowserContext(); if (!this._currentTab) - await context.newPage(); + await browserContext.newPage(); return this._currentTab!; } @@ -273,22 +276,22 @@ ${code.join('\n')} if (this._currentTab === tab) this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; - if (this._browserContext && !this._tabs.length) + if (!this._tabs.length) void this.close(); } async close() { - if (!this._browserContext) + if (!this._browserContextPromise) return; - const browserContext = this._browserContext; - const browser = this._browser; - this._createBrowserContextPromise = undefined; - this._browserContext = undefined; - this._browser = undefined; - await browserContext?.close().then(async () => { - await browser?.close(); - }).catch(() => {}); + const promise = this._browserContextPromise; + this._browserContextPromise = undefined; + + await promise.then(async ({ browserContext, browser }) => { + await browserContext.close().then(async () => { + await browser?.close(); + }).catch(() => {}); + }); } private async _setupRequestInterception(context: playwright.BrowserContext) { @@ -305,30 +308,26 @@ ${code.join('\n')} } } - private async _ensureBrowserContext() { - if (!this._browserContext) { - const context = await this._createBrowserContext(); - this._browser = context.browser; - this._browserContext = context.browserContext; - await this._setupRequestInterception(this._browserContext); - for (const page of this._browserContext.pages()) - this._onPageCreated(page); - this._browserContext.on('page', page => this._onPageCreated(page)); - } - return this._browserContext; - } - - private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { - if (!this._createBrowserContextPromise) { - this._createBrowserContextPromise = this._innerCreateBrowserContext(); - void this._createBrowserContextPromise.catch(() => { - this._createBrowserContextPromise = undefined; + private _ensureBrowserContext() { + if (!this._browserContextPromise) { + this._browserContextPromise = this._setupBrowserContext(); + this._browserContextPromise.catch(() => { + this._browserContextPromise = undefined; }); } - return this._createBrowserContextPromise; + return this._browserContextPromise; } - private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { + private async _setupBrowserContext(): Promise { + const { browser, browserContext } = await this._createBrowserContext(); + await this._setupRequestInterception(browserContext); + for (const page of browserContext.pages()) + this._onPageCreated(page); + browserContext.on('page', page => this._onPageCreated(page)); + return { browser, browserContext }; + } + + private async _createBrowserContext(): Promise { if (this.config.browser?.remoteEndpoint) { const url = new URL(this.config.browser?.remoteEndpoint); if (this.config.browser.browserName) @@ -342,21 +341,37 @@ ${code.join('\n')} if (this.config.browser?.cdpEndpoint) { const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint); - const browserContext = browser.contexts()[0]; + const browserContext = this.config.browser.ephemeral ? await browser.newContext() : browser.contexts()[0]; return { browser, browserContext }; } - const browserContext = await launchPersistentContext(this.config.browser); - return { browserContext }; + return this.config.browser?.ephemeral ? + await launchEphemeralContext(this.config.browser) : + await launchPersistentContext(this.config.browser); } } -async function launchPersistentContext(browserConfig: Config['browser']): Promise { +async function launchEphemeralContext(browserConfig: Config['browser']): Promise { + try { + const browserName = browserConfig?.browserName ?? 'chromium'; + const browserType = playwright[browserName]; + const browser = await browserType.launch(browserConfig?.launchOptions); + const browserContext = await browser.newContext(); + return { browser, browserContext }; + } catch (error: any) { + if (error.message.includes('Executable doesn\'t exist')) + throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); + throw error; + } +} + +async function launchPersistentContext(browserConfig: Config['browser']): Promise { try { const browserName = browserConfig?.browserName ?? 'chromium'; const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName }); const browserType = playwright[browserName]; - return await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions }); + const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions }); + return { browserContext }; } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); diff --git a/src/program.ts b/src/program.ts index 77fb50a..197a4e7 100644 --- a/src/program.ts +++ b/src/program.ts @@ -28,6 +28,7 @@ program .option('--browser ', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--caps ', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') + .option('--ephemeral', 'Keep the browser profile in memory, do not save it to disk.') .option('--executable-path ', 'Path to the browser executable.') .option('--headless', 'Run browser in headless mode, headed by default') .option('--device ', 'Device to emulate, for example: "iPhone 15"') diff --git a/src/tools/common.ts b/src/tools/common.ts index d140380..8a16c35 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -31,7 +31,7 @@ const close = defineTool({ handle: async context => { await context.close(); return { - code: [`// Internal to close the page`], + code: [`await page.close()`], captureSnapshot: false, waitForNetwork: false, }; diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 87a5aa5..da311e9 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -183,6 +183,7 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { command: 'node', args: [path.join(path.dirname(__filename), '../cli.js'), ...args], cwd: path.join(path.dirname(__filename), '..'), + env: process.env as Record, }); } diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index d263abd..26e662b 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -40,3 +40,64 @@ test('executable path', async ({ startClient, server }) => { }); expect(response).toContainTextContent(`executable doesn't exist`); }); + +test('persistent context', async ({ startClient, server }) => { + server.setContent('/', ` + + + + `, 'text/html'); + + const client = await startClient(); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: NO`); + + await new Promise(resolve => setTimeout(resolve, 3000)); + + await client.callTool({ + name: 'browser_close', + }); + + const client2 = await startClient(); + const response2 = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + + expect(response2).toContainTextContent(`Storage: YES`); +}); + +test('ephemeral context', async ({ startClient, server }) => { + server.setContent('/', ` + + + + `, 'text/html'); + + const client = await startClient({ args: [`--ephemeral`] }); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: NO`); + + await client.callTool({ + name: 'browser_close', + }); + + const client2 = await startClient({ args: [`--ephemeral`] }); + const response2 = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response2).toContainTextContent(`Storage: NO`); +});