mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-27 00:52:27 +08:00
feat(ephemeral): allow for non-persistent context operation (#405)
Ref: https://github.com/microsoft/playwright-mcp/issues/367 Ref: https://github.com/microsoft/playwright-mcp/issues/393
This commit is contained in:
parent
a1eee8351e
commit
949f956378
@ -160,6 +160,9 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
// Browser type to use (chromium, firefox, or webkit)
|
// Browser type to use (chromium, firefox, or webkit)
|
||||||
browserName?: 'chromium' | 'firefox' | '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
|
// Path to user data directory for browser profile persistence
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
|
|
||||||
|
5
config.d.ts
vendored
5
config.d.ts
vendored
@ -28,6 +28,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
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.
|
* Path to a user data directory for browser profile persistence.
|
||||||
* Temporary directory is created by default.
|
* Temporary directory is created by default.
|
||||||
|
@ -28,6 +28,7 @@ export type CLIOptions = {
|
|||||||
browser?: string;
|
browser?: string;
|
||||||
caps?: string;
|
caps?: string;
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
|
ephemeral?: boolean;
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
device?: string;
|
device?: string;
|
||||||
@ -106,6 +107,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
return {
|
return {
|
||||||
browser: {
|
browser: {
|
||||||
browserName,
|
browserName,
|
||||||
|
ephemeral: cliOptions.ephemeral,
|
||||||
userDataDir: cliOptions.userDataDir,
|
userDataDir: cliOptions.userDataDir,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
contextOptions,
|
contextOptions,
|
||||||
|
@ -34,12 +34,15 @@ type PendingAction = {
|
|||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrowserContextAndBrowser = {
|
||||||
|
browser?: playwright.Browser;
|
||||||
|
browserContext: playwright.BrowserContext;
|
||||||
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: Config;
|
readonly config: Config;
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
|
||||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
@ -85,7 +88,7 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newTab(): Promise<Tab> {
|
async newTab(): Promise<Tab> {
|
||||||
const browserContext = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
const page = await browserContext.newPage();
|
const page = await browserContext.newPage();
|
||||||
this._currentTab = this._tabs.find(t => t.page === page)!;
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
@ -97,9 +100,9 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureTab(): Promise<Tab> {
|
async ensureTab(): Promise<Tab> {
|
||||||
const context = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
await context.newPage();
|
await browserContext.newPage();
|
||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,22 +276,22 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
if (this._currentTab === tab)
|
if (this._currentTab === tab)
|
||||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||||
if (this._browserContext && !this._tabs.length)
|
if (!this._tabs.length)
|
||||||
void this.close();
|
void this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
if (!this._browserContext)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
const browserContext = this._browserContext;
|
|
||||||
const browser = this._browser;
|
|
||||||
this._createBrowserContextPromise = undefined;
|
|
||||||
this._browserContext = undefined;
|
|
||||||
this._browser = undefined;
|
|
||||||
|
|
||||||
await browserContext?.close().then(async () => {
|
const promise = this._browserContextPromise;
|
||||||
|
this._browserContextPromise = undefined;
|
||||||
|
|
||||||
|
await promise.then(async ({ browserContext, browser }) => {
|
||||||
|
await browserContext.close().then(async () => {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||||
@ -305,30 +308,26 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureBrowserContext() {
|
private _ensureBrowserContext() {
|
||||||
if (!this._browserContext) {
|
if (!this._browserContextPromise) {
|
||||||
const context = await this._createBrowserContext();
|
this._browserContextPromise = this._setupBrowserContext();
|
||||||
this._browser = context.browser;
|
this._browserContextPromise.catch(() => {
|
||||||
this._browserContext = context.browserContext;
|
this._browserContextPromise = undefined;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this._createBrowserContextPromise;
|
return this._browserContextPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||||
|
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<BrowserContextAndBrowser> {
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
if (this.config.browser?.remoteEndpoint) {
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||||
if (this.config.browser.browserName)
|
if (this.config.browser.browserName)
|
||||||
@ -342,21 +341,37 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
if (this.config.browser?.cdpEndpoint) {
|
||||||
const browser = await playwright.chromium.connectOverCDP(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 };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContext = await launchPersistentContext(this.config.browser);
|
return this.config.browser?.ephemeral ?
|
||||||
return { browserContext };
|
await launchEphemeralContext(this.config.browser) :
|
||||||
|
await launchPersistentContext(this.config.browser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
async function launchEphemeralContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
||||||
|
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<BrowserContextAndBrowser> {
|
||||||
try {
|
try {
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
const browserName = browserConfig?.browserName ?? 'chromium';
|
||||||
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
||||||
const browserType = playwright[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) {
|
} catch (error: any) {
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
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 new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
@ -28,6 +28,7 @@ program
|
|||||||
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
|
.option('--ephemeral', 'Keep the browser profile in memory, do not save it to disk.')
|
||||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
.option('--executable-path <path>', 'Path to the browser executable.')
|
||||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||||
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
||||||
|
@ -31,7 +31,7 @@ const close = defineTool({
|
|||||||
handle: async context => {
|
handle: async context => {
|
||||||
await context.close();
|
await context.close();
|
||||||
return {
|
return {
|
||||||
code: [`// Internal to close the page`],
|
code: [`await page.close()`],
|
||||||
captureSnapshot: false,
|
captureSnapshot: false,
|
||||||
waitForNetwork: false,
|
waitForNetwork: false,
|
||||||
};
|
};
|
||||||
|
@ -183,6 +183,7 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
|||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
|
env: process.env as Record<string, string>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,3 +40,64 @@ test('executable path', async ({ startClient, server }) => {
|
|||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persistent context', async ({ startClient, server }) => {
|
||||||
|
server.setContent('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, '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('/', `
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
</script>
|
||||||
|
`, '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`);
|
||||||
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user