diff --git a/docs/src/getting-started-cli.md b/docs/src/getting-started-cli.md
index 4f1e54f6cc7ad..438f7fc6c4114 100644
--- a/docs/src/getting-started-cli.md
+++ b/docs/src/getting-started-cli.md
@@ -287,9 +287,29 @@ playwright-cli open --browser=webkit
playwright-cli open --browser=msedge
```
+### Launch options
+
+Every launch option is available as an `open` flag, so proxies, self-signed dev servers and extensions take one command and no config file:
+
+```bash
+playwright-cli open --proxy=http://user:pass@proxy.example.com:3128 --proxy-bypass=.internal.example.com
+playwright-cli open --ignore-https-errors https://localhost:8443
+playwright-cli open --user-agent="MyAgent/1.0"
+playwright-cli open --executable-path=/path/to/chromium
+playwright-cli open --init-script=./mock-geolocation.js # runs in every page, repeatable
+playwright-cli open --extension=./my-extension # unpacked extension, Chromium only, repeatable, implies --persistent
+playwright-cli open --state=auth.json # cookies and storage saved by state-save
+playwright-cli open --allowed-origins="https://app.example.com;http://localhost:*"
+playwright-cli open --blocked-origins="https://ads.example.com"
+```
+
+When `--proxy` is not given, the `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` environment variables are honored, the same way `curl`, `npm` and `git` honor them. Google Chrome no longer loads unpacked extensions, so `--extension` runs the bundled Chromium unless `--browser` picks another Chromium-based browser.
+
+Each flag has an environment variable counterpart, for example `PLAYWRIGHT_MCP_PROXY_SERVER`, `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS`, `PLAYWRIGHT_MCP_USER_AGENT`, `PLAYWRIGHT_MCP_EXECUTABLE_PATH`, `PLAYWRIGHT_MCP_INIT_SCRIPT`, `PLAYWRIGHT_MCP_LOAD_EXTENSION`, `PLAYWRIGHT_MCP_STORAGE_STATE`, `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` and `PLAYWRIGHT_MCP_BLOCKED_ORIGINS`. Flags win over environment variables, which win over the config file. Run `playwright-cli open --help` for the full list.
+
### Configuration file
-For advanced settings, use a JSON config file:
+For settings that have no flag, use a JSON config file:
```bash
playwright-cli --config path/to/config.json open example.com
@@ -320,6 +340,7 @@ This requires the [Playwright Extension](https://github.com/microsoft/playwright
| **Get page snapshot** | `playwright-cli snapshot` |
| **Run headed** | `playwright-cli open https://example.com --headed` |
| **Use Firefox** | `playwright-cli open --browser=firefox` |
+| **Use a proxy** | `playwright-cli open --proxy=http://proxy:3128` |
| **Monitor sessions** | `playwright-cli show` |
| **List page WebMCP tools** | `playwright-cli webmcp-list` |
diff --git a/packages/playwright-core/src/tools/cli-client/program.ts b/packages/playwright-core/src/tools/cli-client/program.ts
index 246a21a7a2d7d..6dc0ec3a56bd2 100644
--- a/packages/playwright-core/src/tools/cli-client/program.ts
+++ b/packages/playwright-core/src/tools/cli-client/program.ts
@@ -56,6 +56,16 @@ type OpenOptions = {
mobile?: boolean;
persistent?: boolean;
profile?: string;
+ proxy?: string;
+ 'proxy-bypass'?: string;
+ 'ignore-https-errors'?: boolean;
+ 'user-agent'?: string;
+ 'executable-path'?: string;
+ 'init-script'?: string | string[];
+ extension?: string | string[];
+ state?: string;
+ 'allowed-origins'?: string;
+ 'blocked-origins'?: string;
};
const globalOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions))[] = [
diff --git a/packages/playwright-core/src/tools/cli-client/session.ts b/packages/playwright-core/src/tools/cli-client/session.ts
index c8d810d8ab054..aa2e886083750 100644
--- a/packages/playwright-core/src/tools/cli-client/session.ts
+++ b/packages/playwright-core/src/tools/cli-client/session.ts
@@ -139,12 +139,35 @@ export class Session {
args.push(`--config=${cliArgs.config}`);
if (cliArgs['idle-timeout'] !== undefined)
args.push(`--idle-timeout=${cliArgs['idle-timeout']}`);
- if (cliArgs.extension)
+ if (mode === 'open') {
+ // `open --extension=
` loads an unpacked extension, unlike `attach --extension` that connects through the Playwright Extension.
+ for (const dir of flagValues(cliArgs, 'extension'))
+ args.push(`--load-extension=${dir}`);
+ for (const script of flagValues(cliArgs, 'init-script'))
+ args.push(`--init-script=${script}`);
+ if (cliArgs['ignore-https-errors'])
+ args.push('--ignore-https-errors');
+ const singleValueFlags: [flag: string, daemonFlag: string][] = [
+ ['proxy', 'proxy-server'],
+ ['proxy-bypass', 'proxy-bypass'],
+ ['user-agent', 'user-agent'],
+ ['executable-path', 'executable-path'],
+ ['state', 'storage-state'],
+ ['allowed-origins', 'allowed-origins'],
+ ['blocked-origins', 'blocked-origins'],
+ ];
+ for (const [flag, daemonFlag] of singleValueFlags) {
+ const value = flagValues(cliArgs, flag).at(-1);
+ if (value !== undefined)
+ args.push(`--${daemonFlag}=${value}`);
+ }
+ } else if (cliArgs.extension) {
args.push('--extension');
- else if (cliArgs.cdp)
+ } else if (cliArgs.cdp) {
args.push(`--cdp=${cliArgs.cdp}`);
- else if (cliArgs.endpoint)
+ } else if (cliArgs.endpoint) {
args.push(`--endpoint=${cliArgs.endpoint}`);
+ }
const child = spawn(process.execPath, args, {
detached: true,
@@ -206,6 +229,16 @@ export class Session {
}
}
+// Minimist yields `true` for a flag without a value, a string for one occurrence and an array for a repeated one.
+function flagValues(cliArgs: MinimistArgs, flag: string): string[] {
+ const value = cliArgs[flag];
+ if (value === undefined || value === false)
+ return [];
+ if (value === true)
+ throw new Error(`error: '--${flag}' option requires a value`);
+ return Array.isArray(value) ? value.map(String) : [String(value)];
+}
+
class SocketConnectionClient {
private _connection: SocketConnection;
private _nextMessageId = 1;
diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts
index c28b54f855adc..d00dfde65f05d 100644
--- a/packages/playwright-core/src/tools/cli-daemon/commands.ts
+++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts
@@ -46,7 +46,7 @@ const open = declareCommand({
url: z.string().optional().describe('The URL to navigate to'),
}),
options: z.object({
- browser: z.string().optional().describe('Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.'),
+ browser: z.string().optional().describe('Browser or chrome channel to use, possible values: chrome, chromium, firefox, webkit, msedge.'),
config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'),
device: z.string().optional().describe('Emulate a specific device, for example "iPhone 15".'),
headed: z.boolean().optional().describe('Run browser in headed mode'),
@@ -54,6 +54,16 @@ const open = declareCommand({
mobile: z.boolean().optional().describe('Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens.'),
persistent: z.boolean().optional().describe('Use persistent browser profile'),
profile: z.string().optional().describe('Path to a persistent user data directory.'),
+ proxy: z.string().optional().describe('Proxy server, for example "http://myproxy:3128", "http://user:pass@myproxy:3128" or "socks5://myproxy:8080". Defaults to HTTPS_PROXY or HTTP_PROXY from the environment.'),
+ ['proxy-bypass']: z.string().optional().describe('Comma-separated domains to bypass the proxy, for example ".com,chromium.org,.domain.com". Defaults to NO_PROXY from the environment.'),
+ ['ignore-https-errors']: z.boolean().optional().describe('Ignore HTTPS errors, for example from self-signed certificates on a dev server.'),
+ ['user-agent']: z.string().optional().describe('User agent string.'),
+ ['executable-path']: z.string().optional().describe('Path to the browser executable.'),
+ ['init-script']: stringArrayArg.optional().describe('Path to a JavaScript file to evaluate in every page before any of the page\'s scripts (repeatable).'),
+ extension: stringArrayArg.optional().describe('Path to an unpacked extension directory to load, Chromium only, implies --persistent (repeatable).'),
+ state: z.string().optional().describe('Path to a storage state file (cookies, localStorage) to start the session with, see state-save.'),
+ ['allowed-origins']: z.string().optional().describe('Semicolon-separated list of origins the browser is allowed to request, everything else is blocked.'),
+ ['blocked-origins']: z.string().optional().describe('Semicolon-separated list of origins the browser is blocked from requesting.'),
}),
toolName: '',
toolParams: () => ({}),
diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts
index 1e2b39af7befd..78aee07b34233 100644
--- a/packages/playwright-core/src/tools/cli-daemon/program.ts
+++ b/packages/playwright-core/src/tools/cli-daemon/program.ts
@@ -45,6 +45,16 @@ export function decorateProgram(program: Command) {
.option('--cdp ', 'connect to an existing browser via CDP endpoint URL')
.option('--endpoint ', 'attach to a running Playwright browser endpoint')
.option('--idle-timeout ', 'shut the session down after this many milliseconds without a command, defaults to one hour for headless browsers', configUtils.numberParser)
+ .option('--proxy-server ', 'proxy server, for example "http://myproxy:3128", "http://user:pass@myproxy:3128" or "socks5://myproxy:8080"')
+ .option('--proxy-bypass ', 'comma-separated domains to bypass the proxy, for example ".com,chromium.org,.domain.com"')
+ .option('--ignore-https-errors', 'ignore HTTPS errors, for example from self-signed certificates')
+ .option('--user-agent ', 'user agent string')
+ .option('--executable-path ', 'path to the browser executable')
+ .option('--init-script ', 'path to a JavaScript file to evaluate in every page before any of the page\'s scripts, can be specified multiple times')
+ .option('--load-extension ', 'path to an unpacked extension directory to load into the browser, can be specified multiple times')
+ .option('--storage-state ', 'path to a storage state file to start the session with')
+ .option('--allowed-origins ', 'semicolon-separated list of origins the browser is allowed to request', configUtils.semicolonSeparatedList)
+ .option('--blocked-origins ', 'semicolon-separated list of origins the browser is blocked from requesting', configUtils.semicolonSeparatedList)
.option('--init-workspace', 'initialize workspace')
.option('--init-skills ', 'install skills for the given agent type ("claude" or "agents")')
.option('--init-skills-global ', 'install skills for the given agent type ("claude" or "agents") into the home directory')
diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts
index 1971b09db6ee4..a8c53f2927c5d 100644
--- a/packages/playwright-core/src/tools/mcp/config.d.ts
+++ b/packages/playwright-core/src/tools/mcp/config.d.ts
@@ -100,6 +100,13 @@ export type Config = {
* The scripts will be evaluated in every page before any of the page's scripts.
*/
initScript?: string[];
+
+ /**
+ * Paths to unpacked extension directories to load into the browser. Chromium only.
+ * Google Chrome no longer loads unpacked extensions, so the bundled Chromium build is used
+ * unless a browser is picked explicitly.
+ */
+ extensions?: string[];
},
/**
diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts
index 28062016e4f30..26e34df984360 100644
--- a/packages/playwright-core/src/tools/mcp/config.ts
+++ b/packages/playwright-core/src/tools/mcp/config.ts
@@ -30,6 +30,10 @@ async function fileExistsAsync(resolved: string) {
try { return (await fs.promises.stat(resolved)).isFile(); } catch { return false; }
}
+async function directoryExistsAsync(resolved: string) {
+ try { return (await fs.promises.stat(resolved)).isDirectory(); } catch { return false; }
+}
+
type ViewportSize = { width: number; height: number };
export type CLIOptions = {
@@ -60,6 +64,7 @@ export type CLIOptions = {
isolated?: boolean;
idleTimeout?: number;
imageResponses?: 'allow' | 'omit' | 'only';
+ loadExtension?: string[];
mobile?: boolean;
sandbox?: boolean;
outputDir?: string;
@@ -138,6 +143,7 @@ export async function resolveCLIConfigForMCP(cliOptions: CLIOptions, env?: NodeJ
result = mergeConfig(result, resolveConfigPaths(configInFile, configDir));
result = mergeConfig(result, resolveConfigPaths(envOverrides, process.cwd()));
result = mergeConfig(result, resolveConfigPaths(cliOverrides, process.cwd()));
+ applyProxyFromEnvironment(result, env);
const browser = await validateBrowserConfig(result.browser);
if (browser.launchOptions.headless === undefined)
@@ -175,6 +181,16 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN
extension: options.extension,
userDataDir: options.profile,
idleTimeout: options.idleTimeout,
+ allowedOrigins: options.allowedOrigins,
+ blockedOrigins: options.blockedOrigins,
+ executablePath: options.executablePath,
+ ignoreHttpsErrors: options.ignoreHttpsErrors,
+ initScript: options.initScript,
+ loadExtension: options.loadExtension,
+ proxyBypass: options.proxyBypass,
+ proxyServer: options.proxyServer,
+ storageState: options.storageState,
+ userAgent: options.userAgent,
snapshotMode: 'full',
});
@@ -192,9 +208,11 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN
result = mergeConfig(result, resolveConfigPaths(configInFile, configDir));
result = mergeConfig(result, resolveConfigPaths(envOverrides, process.cwd()));
result = mergeConfig(result, resolveConfigPaths(daemonOverrides, process.cwd()));
+ applyProxyFromEnvironment(result, env);
+ // Extensions only load into a persistent profile, so they use the session's on-disk profile like --persistent does.
if (result.browser.isolated === undefined)
- result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir && !result.browser.remoteEndpoint && !result.browser.cdpEndpoint && !result.extension;
+ result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir && !result.browser.remoteEndpoint && !result.browser.cdpEndpoint && !result.extension && !result.browser.extensions?.length;
if (result.browser.launchOptions.headless === undefined)
result.browser.launchOptions.headless = true;
@@ -222,12 +240,38 @@ export function resolveExtensionOptions(cliOptions: CLIOptions): { channel: stri
}
async function validateBrowserConfig(browser: MergedConfig['browser']): Promise {
+ const extensions = browser.extensions?.length ? browser.extensions : undefined;
let browserName = browser.browserName;
if (!browserName) {
browserName = 'chromium';
// Assign channel only if the browserName is not provided, otherwise assume full control to the user.
+ // Google Chrome no longer loads unpacked extensions, so they get the bundled Chromium build instead.
if (browser.launchOptions.channel === undefined)
- browser.launchOptions.channel = 'chrome';
+ browser.launchOptions.channel = extensions ? 'chromium' : 'chrome';
+ }
+
+ if (extensions) {
+ if (browserName !== 'chromium')
+ throw new Error(`Extensions are only supported in Chromium-based browsers, cannot load them into ${browserName}.`);
+ if (browser.isolated)
+ throw new Error('Extensions only load into a persistent browser profile, do not combine them with isolated mode.');
+ const { channel } = browser.launchOptions;
+ if (channel?.startsWith('chrome') && channel !== 'chrome-for-testing')
+ throw new Error(`Google Chrome does not load unpacked extensions, use --browser=chromium instead of ${channel}.`);
+ // The headless shell that runs without a channel has no extension support, the full build does.
+ if (channel === undefined)
+ browser.launchOptions.channel = 'chromium';
+ for (const extension of extensions) {
+ if (!await directoryExistsAsync(extension))
+ throw new Error(`Extension directory does not exist: ${extension}`);
+ if (!await fileExistsAsync(path.join(extension, 'manifest.json')))
+ throw new Error(`Extension directory has no manifest.json: ${extension}`);
+ }
+ const list = extensions.join(',');
+ browser.launchOptions.args = [...(browser.launchOptions.args ?? []), `--disable-extensions-except=${list}`, `--load-extension=${list}`];
+ const ignoreDefaultArgs = browser.launchOptions.ignoreDefaultArgs;
+ if (ignoreDefaultArgs !== true)
+ browser.launchOptions.ignoreDefaultArgs = [...(Array.isArray(ignoreDefaultArgs) ? ignoreDefaultArgs : []), '--disable-extensions'];
}
if (browserName === 'chromium' && browser.launchOptions.chromiumSandbox === undefined) {
@@ -328,9 +372,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
const contextOptions: playwrightTypes.BrowserContextOptions = device ? playwright.devices[device] : {};
if (cliOptions.proxyServer) {
- const proxy: playwrightTypes.LaunchOptions['proxy'] = { server: cliOptions.proxyServer };
- if (cliOptions.proxyBypass)
- proxy.bypass = cliOptions.proxyBypass;
+ const proxy = proxyFromServer(cliOptions.proxyServer, cliOptions.proxyBypass);
// Set on both to ensure CLI takes precedence over any proxy set in the config file
// (launchOptions.proxy applies at browser launch, contextOptions.proxy at context creation).
launchOptions.proxy = proxy;
@@ -367,6 +409,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
cdpTimeout: cliOptions.cdpTimeout,
initPage: cliOptions.initPage,
initScript: cliOptions.initScript,
+ extensions: cliOptions.loadExtension,
remoteEndpoint: cliOptions.endpoint,
},
extension: cliOptions.extension,
@@ -448,6 +491,9 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
if (initScript)
options.initScript = [initScript];
options.isolated = envToBoolean(e.PLAYWRIGHT_MCP_ISOLATED);
+ const loadExtension = envToString(e.PLAYWRIGHT_MCP_LOAD_EXTENSION);
+ if (loadExtension)
+ options.loadExtension = [loadExtension];
if (e.PLAYWRIGHT_MCP_IMAGE_RESPONSES)
options.imageResponses = enumParser<'allow' | 'omit' | 'only'>('--image-responses', ['allow', 'omit', 'only'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES);
options.mobile = envToBoolean(e.PLAYWRIGHT_MCP_MOBILE);
@@ -493,18 +539,60 @@ export async function loadConfig(configFile: string | undefined): Promise path.resolve(baseDir, p));
if (config.browser?.initScript)
config.browser.initScript = config.browser.initScript.map(p => path.resolve(baseDir, p));
+ if (config.browser?.extensions)
+ config.browser.extensions = config.browser.extensions.map(p => path.resolve(baseDir, p));
return config;
}
+// Browsers ignore credentials embedded in the proxy URL, for example
+// http://user:pass@proxy:3128, so move them into the username/password fields.
+function proxyFromServer(server: string, bypass?: string): NonNullable {
+ const proxy: NonNullable = { server };
+ if (bypass)
+ proxy.bypass = bypass;
+ // Short forms like "myproxy:3128" are not URLs, leave them to the browser.
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(server))
+ return proxy;
+ let url: URL;
+ try {
+ url = new URL(server);
+ } catch {
+ return proxy;
+ }
+ if (!url.username && !url.password)
+ return proxy;
+ proxy.server = `${url.protocol}//${url.host}`;
+ proxy.username = decodeURIComponent(url.username);
+ proxy.password = decodeURIComponent(url.password);
+ return proxy;
+}
+
+// Fall back to the HTTPS_PROXY / HTTP_PROXY / NO_PROXY variables that curl, npm and git
+// honor, so a proxied environment works without flags or a config file. Anything
+// configured explicitly, in a file, env var or flag, takes precedence.
+function applyProxyFromEnvironment(config: MergedConfig, env?: NodeJS.ProcessEnv) {
+ if (config.browser.launchOptions.proxy || config.browser.contextOptions.proxy)
+ return;
+ const e = env ?? process.env;
+ const server = envToString(e.HTTPS_PROXY ?? e.https_proxy ?? e.HTTP_PROXY ?? e.http_proxy);
+ if (!server)
+ return;
+ // NO_PROXY is a comma- or space-separated host list, Playwright wants it comma-separated.
+ const bypass = envToString(e.NO_PROXY ?? e.no_proxy)?.split(/[\s,]+/).filter(Boolean).join(',');
+ const proxy = proxyFromServer(server, bypass);
+ config.browser.launchOptions.proxy = proxy;
+ config.browser.contextOptions.proxy = proxy;
+}
+
function pickDefined(obj: T | undefined): Partial {
return Object.fromEntries(
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts
index eec1b451588ab..5fb1b61beb19a 100644
--- a/packages/playwright-core/src/tools/mcp/program.ts
+++ b/packages/playwright-core/src/tools/mcp/program.ts
@@ -60,6 +60,7 @@ export function decorateMCPCommand(command: Command) {
.option('--init-page ', 'path to TypeScript file to evaluate on Playwright page object')
.option('--init-script ', 'path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page\'s scripts. Can be specified multiple times.')
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
+ .option('--load-extension ', 'path to an unpacked extension directory to load into the browser, Chromium only. Can be specified multiple times.')
.option('--image-responses ', 'whether to send image responses to the client. Can be "allow", "omit" or "only". With "only", a response that carries an image consists of the image parts alone, without the text part. Defaults to "allow".', enumParser.bind(null, '--image-responses', ['allow', 'omit', 'only']))
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--no-webmcp', 'do not collect or expose the tools that a page registers through the WebMCP API.')
@@ -68,7 +69,7 @@ export function decorateMCPCommand(command: Command) {
.option('--port ', 'port to listen on for SSE transport.')
.option('--profile-dir-name ', 'name of the profile directory in the user data dir to connect to with --extension, for example "Profile 1". Defaults to the last used profile that has the extension installed.')
.option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
- .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
+ .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128", "http://user:pass@myproxy:3128" or "socks5://myproxy:8080". Defaults to HTTPS_PROXY or HTTP_PROXY from the environment.')
.addOption(new ProgramOption('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.').argParser(headerParser).hideHelp())
.option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.')
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md
index b10dc7b3ebaa0..49efb6722807a 100644
--- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md
+++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md
@@ -294,7 +294,28 @@ playwright-cli attach --cdp=msedge
# Connect to a running browser via CDP endpoint
playwright-cli attach --cdp=http://localhost:9222
-# Start with config file
+# Route traffic through a proxy, credentials in the URL are supported.
+# HTTPS_PROXY / HTTP_PROXY / NO_PROXY from the environment are used when --proxy is not given.
+playwright-cli open --proxy=http://user:pass@proxy.example.com:3128 --proxy-bypass=.internal.example.com
+# Accept self-signed certificates, for example from a local dev server
+playwright-cli open --ignore-https-errors https://localhost:8443
+# Custom user agent
+playwright-cli open --user-agent="MyAgent/1.0"
+# Use a specific browser binary
+playwright-cli open --executable-path=/path/to/chromium
+# Evaluate a script in every page before the page's own scripts (repeatable)
+playwright-cli open --init-script=./mock-geolocation.js
+# Load an unpacked extension (repeatable). Chrome no longer loads unpacked extensions,
+# so this runs the bundled Chromium unless --browser picks another Chromium-based browser.
+# Extensions need a profile on disk, so this implies --persistent.
+playwright-cli open --extension=./my-extension
+# Start with cookies and localStorage saved by state-save
+playwright-cli open --state=auth.json
+# Restrict which origins the browser may request (everything else is blocked), or block specific ones
+playwright-cli open --allowed-origins="https://app.example.com;http://localhost:*"
+playwright-cli open --blocked-origins="https://ads.example.com"
+
+# Start with config file (only needed for options that have no flag)
playwright-cli open --config=my-config.json
# Close the browser
@@ -305,6 +326,36 @@ playwright-cli -s=msedge detach
playwright-cli delete-data
```
+## Environment variables
+
+Every `open` option above can be set through the environment instead of a flag, so an agent
+can be configured once without writing a config file. Flags win over environment variables,
+which win over the config file.
+
+| Flag | Environment variable |
+| --- | --- |
+| `--browser` | `PLAYWRIGHT_MCP_BROWSER` |
+| `--headed` | `PLAYWRIGHT_MCP_HEADLESS=false` |
+| `--device` | `PLAYWRIGHT_MCP_DEVICE` |
+| `--proxy` | `PLAYWRIGHT_MCP_PROXY_SERVER`, or `HTTPS_PROXY` / `HTTP_PROXY` |
+| `--proxy-bypass` | `PLAYWRIGHT_MCP_PROXY_BYPASS`, or `NO_PROXY` |
+| `--ignore-https-errors` | `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS=1` |
+| `--user-agent` | `PLAYWRIGHT_MCP_USER_AGENT` |
+| `--executable-path` | `PLAYWRIGHT_MCP_EXECUTABLE_PATH` |
+| `--init-script` | `PLAYWRIGHT_MCP_INIT_SCRIPT` |
+| `--extension` | `PLAYWRIGHT_MCP_LOAD_EXTENSION` |
+| `--state` | `PLAYWRIGHT_MCP_STORAGE_STATE` |
+| `--allowed-origins` | `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` |
+| `--blocked-origins` | `PLAYWRIGHT_MCP_BLOCKED_ORIGINS` |
+| `--idle-timeout` | `PLAYWRIGHT_MCP_IDLE_TIMEOUT` |
+| `-s` / `--session` | `PLAYWRIGHT_CLI_SESSION` |
+
+```bash
+export HTTPS_PROXY=http://proxy.example.com:3128
+export PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS=1
+playwright-cli open https://localhost:8443
+```
+
## URLs with `&` on Windows
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md b/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md
index eb1bde35d9ce6..aed0882140919 100644
--- a/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md
+++ b/packages/playwright-core/src/tools/skills/playwright-cli/references/session-management.md
@@ -176,12 +176,9 @@ playwright-cli close # Stops default browser
## Browser Session Configuration
-Configure a browser session with specific settings when opening:
+Configure a browser session with specific settings when opening. Every launch option is a flag, so no config file is needed:
```bash
-# Open with config file
-playwright-cli open https://example.com --config=.playwright/my-cli.json
-
# Open with specific browser
playwright-cli open https://example.com --browser=firefox
@@ -190,8 +187,38 @@ playwright-cli open https://example.com --headed
# Open with persistent profile
playwright-cli open https://example.com --persistent
+
+# Route traffic through a proxy (credentials in the URL are supported);
+# HTTPS_PROXY / HTTP_PROXY / NO_PROXY from the environment are used when --proxy is not given
+playwright-cli open https://example.com --proxy=http://user:pass@proxy.example.com:3128 --proxy-bypass=.internal.example.com
+
+# Accept self-signed certificates from a local dev server
+playwright-cli open https://localhost:8443 --ignore-https-errors
+
+# Custom user agent or browser binary
+playwright-cli open https://example.com --user-agent="MyAgent/1.0"
+playwright-cli open https://example.com --executable-path=/path/to/chromium
+
+# Run a script in every page before the page's own scripts (repeatable)
+playwright-cli open https://example.com --init-script=./mock-geolocation.js
+
+# Load an unpacked extension (repeatable, Chromium only; runs the bundled Chromium
+# because Chrome no longer loads unpacked extensions, and implies --persistent)
+playwright-cli open https://example.com --extension=./my-extension
+
+# Start with cookies and localStorage saved by state-save
+playwright-cli open https://example.com --state=auth.json
+
+# Restrict which origins the browser may request
+playwright-cli open https://example.com --allowed-origins="https://example.com;http://localhost:*"
+playwright-cli open https://example.com --blocked-origins="https://ads.example.com"
+
+# Open with config file, for options that have no flag
+playwright-cli open https://example.com --config=.playwright/my-cli.json
```
+Each flag has an environment variable counterpart (`PLAYWRIGHT_MCP_PROXY_SERVER`, `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS`, `PLAYWRIGHT_MCP_USER_AGENT`, `PLAYWRIGHT_MCP_EXECUTABLE_PATH`, `PLAYWRIGHT_MCP_INIT_SCRIPT`, `PLAYWRIGHT_MCP_LOAD_EXTENSION`, `PLAYWRIGHT_MCP_STORAGE_STATE`, `PLAYWRIGHT_MCP_ALLOWED_ORIGINS`, `PLAYWRIGHT_MCP_BLOCKED_ORIGINS`), see the environment variables table in the skill.
+
## Best Practices
### 1. Name Browser Sessions Semantically
diff --git a/tests/mcp/cli-open-options.spec.ts b/tests/mcp/cli-open-options.spec.ts
new file mode 100644
index 0000000000000..598ea2a26ed80
--- /dev/null
+++ b/tests/mcp/cli-open-options.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * 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 { test, expect } from './cli-fixtures';
+
+const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/;
+
+test('--proxy routes browser traffic through the proxy', async ({ cli, server }) => {
+ server.setRoute('/target.html', (_req, res) => {
+ res.end('Served by the proxy');
+ });
+ await cli('open', `--proxy=${server.HOST}`);
+ const { output } = await cli('goto', 'http://non-existent.com/target.html');
+ expect(output).toContain('Served by the proxy');
+});
+
+test('--proxy sends credentials embedded in the URL', async ({ cli, server }) => {
+ server.setRoute('/target.html', (req, res) => {
+ const auth = req.headers['proxy-authorization'];
+ if (!auth) {
+ res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="proxy"' });
+ res.end();
+ return;
+ }
+ const [user, password] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
+ res.end(`Proxy user ${user} password ${password}`);
+ });
+ await cli('open', `--proxy=http://alice:s3cret@${server.HOST}`);
+ const { output } = await cli('goto', 'http://non-existent.com/target.html');
+ expect(output).toContain('Proxy user alice password s3cret');
+});
+
+test('HTTPS_PROXY from the environment is used without flags', async ({ cli, server }) => {
+ server.setRoute('/target.html', (_req, res) => {
+ res.end('Served by the environment proxy');
+ });
+ const env = { HTTPS_PROXY: `http://${server.HOST}`, HTTP_PROXY: `http://${server.HOST}` };
+ await cli('open', { env });
+ const { output } = await cli('goto', 'http://non-existent.com/target.html');
+ expect(output).toContain('Served by the environment proxy');
+});
+
+test('--ignore-https-errors accepts a self-signed certificate', async ({ cli, httpsServer }) => {
+ await cli('open', '--ignore-https-errors', httpsServer.EMPTY_PAGE);
+ const { output } = await cli('eval', 'location.href');
+ expect(output).toContain(httpsServer.EMPTY_PAGE);
+});
+
+test('self-signed certificate is rejected by default', async ({ cli, httpsServer }) => {
+ await cli('open');
+ const { output, error } = await cli('goto', httpsServer.EMPTY_PAGE);
+ expect(output + error).toMatch(/ERR_CERT|SSL|certificate|SEC_ERROR/i);
+});
+
+test('--user-agent sets the user agent', async ({ cli, server }) => {
+ await cli('open', '--user-agent=Agent/1.0', server.HELLO_WORLD);
+ const { output } = await cli('eval', 'navigator.userAgent');
+ expect(output).toContain('Agent/1.0');
+});
+
+test('--state loads cookies and storage from a file relative to cwd', async ({ cli, server }, testInfo) => {
+ await fs.promises.writeFile(testInfo.outputPath('state.json'), JSON.stringify({
+ origins: [{ origin: server.PREFIX, localStorage: [{ name: 'test', value: 'session-value' }] }],
+ }));
+ server.setContent('/', `
+
+
+ `, 'text/html');
+ await cli('open', '--state=state.json', server.PREFIX);
+ const { output } = await cli('eval', 'document.body.textContent');
+ expect(output).toContain('Storage: session-value');
+});
+
+test('--init-script runs in every page before its scripts', async ({ cli, server }, testInfo) => {
+ await fs.promises.writeFile(testInfo.outputPath('init.js'), 'window.injected = "from-init-script";');
+ await cli('open', '--init-script=init.js', server.HELLO_WORLD);
+ const { output } = await cli('eval', 'window.injected');
+ expect(output).toContain('from-init-script');
+ await cli('goto', server.EMPTY_PAGE);
+ const { output: afterNavigation } = await cli('eval', 'window.injected');
+ expect(afterNavigation).toContain('from-init-script');
+});
+
+test('--init-script can be repeated', async ({ cli, server }, testInfo) => {
+ await fs.promises.writeFile(testInfo.outputPath('a.js'), 'window.a = 1;');
+ await fs.promises.writeFile(testInfo.outputPath('b.js'), 'window.b = window.a + 1;');
+ await cli('open', '--init-script=a.js', '--init-script=b.js', server.HELLO_WORLD);
+ const { output } = await cli('eval', 'window.a + "," + window.b');
+ expect(output).toContain('1,2');
+});
+
+test('--allowed-origins blocks everything else', async ({ cli, server }) => {
+ server.setContent('/ppp', 'content:PPP', 'text/html');
+ await cli('open', `--allowed-origins=${new URL(server.PREFIX).origin}`, server.PREFIX + '/ppp');
+ const { output } = await cli('eval', 'document.body.textContent');
+ expect(output).toContain('content:PPP');
+ const { output: blocked, error } = await cli('goto', 'https://example.com/');
+ expect(blocked + error).toMatch(BLOCK_MESSAGE);
+});
+
+test('--blocked-origins blocks the listed origins', async ({ cli, server }) => {
+ server.setContent('/ppp', 'content:PPP', 'text/html');
+ await cli('open', '--blocked-origins=example.com', server.PREFIX + '/ppp');
+ const { output } = await cli('eval', 'document.body.textContent');
+ expect(output).toContain('content:PPP');
+ const { output: blocked, error } = await cli('goto', 'https://example.com/');
+ expect(blocked + error).toMatch(BLOCK_MESSAGE);
+});
+
+test('--executable-path reports a missing binary', async ({ cli }) => {
+ const { exitCode, output, error } = await cli('open', '--executable-path=bogus');
+ expect(exitCode).not.toBe(0);
+ expect(output + error).toMatch(/executable doesn't exist/i);
+});
+
+async function writeExtension(dir: string) {
+ await fs.promises.mkdir(dir, { recursive: true });
+ await fs.promises.writeFile(path.join(dir, 'manifest.json'), JSON.stringify({
+ name: 'Marker extension',
+ version: '1.0',
+ manifest_version: 3,
+ content_scripts: [{ matches: [''], js: ['content.js'], run_at: 'document_start' }],
+ }));
+ await fs.promises.writeFile(path.join(dir, 'content.js'), `document.documentElement.setAttribute('data-extension', 'loaded');`);
+}
+
+test('--extension loads an unpacked extension into the bundled chromium', async ({ cli, server, mcpBrowser }, testInfo) => {
+ test.skip(mcpBrowser !== 'chrome' && mcpBrowser !== 'chromium', 'Unpacked extensions are a Chromium feature.');
+ await writeExtension(testInfo.outputPath('ext'));
+ await cli('open', '--browser=chromium', '--extension=ext', server.HELLO_WORLD);
+ const { output } = await cli('eval', 'document.documentElement.getAttribute("data-extension")');
+ expect(output).toContain('loaded');
+ const { output: list } = await cli('list');
+ expect(list).toContain('browser-type: chrome-for-testing');
+});
+
+test('--extension picks the bundled chromium when no browser is configured', async ({ cli, server, mcpBrowser }, testInfo) => {
+ test.skip(mcpBrowser !== 'chrome' && mcpBrowser !== 'chromium', 'Unpacked extensions are a Chromium feature.');
+ await writeExtension(testInfo.outputPath('ext'));
+ // The fixture pins the browser through PLAYWRIGHT_MCP_BROWSER, drop it to exercise the default.
+ await cli('open', '--extension=ext', server.HELLO_WORLD, { env: { PLAYWRIGHT_MCP_BROWSER: '' } });
+ const { output } = await cli('eval', 'document.documentElement.getAttribute("data-extension")');
+ expect(output).toContain('loaded');
+ const { output: list } = await cli('list');
+ expect(list).toContain('browser-type: chromium');
+});
+
+test('--extension is rejected with chrome', async ({ cli, mcpBrowser }, testInfo) => {
+ test.skip(mcpBrowser !== 'chrome', 'Only Google Chrome refuses unpacked extensions.');
+ await writeExtension(testInfo.outputPath('ext'));
+ const { exitCode, output, error } = await cli('open', '--extension=ext');
+ expect(exitCode).not.toBe(0);
+ expect(output + error).toContain('Google Chrome does not load unpacked extensions, use --browser=chromium instead of chrome.');
+});
+
+test('--extension reports a missing directory', async ({ cli }) => {
+ const { exitCode, output, error } = await cli('open', '--browser=chromium', '--extension=missing');
+ expect(exitCode).not.toBe(0);
+ expect(output + error).toContain('Extension directory does not exist');
+});
+
+test('a value flag without a value is rejected', async ({ cli }) => {
+ const { exitCode, output, error } = await cli('open', '--extension');
+ expect(exitCode).not.toBe(0);
+ expect(output + error).toContain(`'--extension' option requires a value`);
+ const { output: list } = await cli('list');
+ expect(list).toContain('(no browsers)');
+});
+
+test('open help lists the launch options', async ({ cli }) => {
+ const { output } = await cli('open', '--help');
+ for (const flag of ['--proxy', '--proxy-bypass', '--ignore-https-errors', '--user-agent', '--executable-path', '--init-script', '--extension', '--state', '--allowed-origins', '--blocked-origins'])
+ expect(output).toContain(flag);
+});
diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts
index 7890dd522e113..969f33050153f 100644
--- a/tests/mcp/config-resolve.spec.ts
+++ b/tests/mcp/config-resolve.spec.ts
@@ -389,6 +389,156 @@ test.describe('merge order', () => {
});
});
+// ---------------------------------------------------------------------------
+// Shared behavior — proxy
+// ---------------------------------------------------------------------------
+
+test.describe('proxy', () => {
+ test('--proxy-server moves credentials from the URL into username and password', async () => {
+ const config = await resolveCLIConfigForMCP({ proxyServer: 'http://alice:s%40cret@proxy.example.com:3128', proxyBypass: '.internal' }, emptyEnv);
+ const proxy = { server: 'http://proxy.example.com:3128', username: 'alice', password: 's@cret', bypass: '.internal' };
+ expect(config.browser.launchOptions.proxy).toEqual(proxy);
+ expect(config.browser.contextOptions.proxy).toEqual(proxy);
+ });
+
+ test('--proxy-server keeps a URL without credentials as is', async () => {
+ const config = await resolveCLIConfigForMCP({ proxyServer: 'socks5://proxy.example.com:1080' }, emptyEnv);
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'socks5://proxy.example.com:1080' });
+ });
+
+ test('--proxy-server keeps the short form without a scheme as is', async () => {
+ const config = await resolveCLIConfigForMCP({ proxyServer: 'proxy.example.com:3128' }, emptyEnv);
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'proxy.example.com:3128' });
+ });
+
+ test('HTTPS_PROXY from the environment is used when no proxy is configured', async () => {
+ const config = await resolveCLIConfigForMCP({}, { HTTPS_PROXY: 'http://bob:pw@proxy.example.com:8080', HTTP_PROXY: 'http://other:1', NO_PROXY: 'localhost, .example.com' });
+ const proxy = { server: 'http://proxy.example.com:8080', username: 'bob', password: 'pw', bypass: 'localhost,.example.com' };
+ expect(config.browser.launchOptions.proxy).toEqual(proxy);
+ expect(config.browser.contextOptions.proxy).toEqual(proxy);
+ });
+
+ test('HTTP_PROXY from the environment is used when HTTPS_PROXY is not set', async () => {
+ const config = await resolveCLIConfigForMCP({}, { http_proxy: 'http://proxy.example.com:8080' });
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'http://proxy.example.com:8080' });
+ });
+
+ test('no proxy without environment variables', async () => {
+ const config = await resolveCLIConfigForMCP({}, emptyEnv);
+ expect(config.browser.launchOptions.proxy).toBeUndefined();
+ expect(config.browser.contextOptions.proxy).toBeUndefined();
+ });
+
+ test('config file proxy wins over HTTPS_PROXY', async ({}, testInfo) => {
+ const configFile = testInfo.outputPath('config.json');
+ const fileConfig: Config = {
+ browser: { contextOptions: { proxy: { server: 'http://from-config:1' } } },
+ };
+ await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
+ const config = await resolveCLIConfigForMCP({ config: configFile }, { HTTPS_PROXY: 'http://from-env:2' });
+ expect(config.browser.contextOptions.proxy).toEqual({ server: 'http://from-config:1' });
+ expect(config.browser.launchOptions.proxy).toBeUndefined();
+ });
+
+ test('--proxy-server wins over HTTPS_PROXY', async () => {
+ const config = await resolveCLIConfigForMCP({ proxyServer: 'http://from-flag:1' }, { HTTPS_PROXY: 'http://from-env:2' });
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'http://from-flag:1' });
+ });
+
+ test('PLAYWRIGHT_MCP_PROXY_SERVER wins over HTTPS_PROXY', async () => {
+ const config = await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_PROXY_SERVER: 'http://from-mcp-env:1', HTTPS_PROXY: 'http://from-env:2' });
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'http://from-mcp-env:1' });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Shared behavior — extensions
+// ---------------------------------------------------------------------------
+
+test.describe('extensions', () => {
+ async function createExtension(dir: string) {
+ await fs.promises.mkdir(dir, { recursive: true });
+ await fs.promises.writeFile(path.join(dir, 'manifest.json'), JSON.stringify({ name: 'ext', version: '1', manifest_version: 3 }));
+ return dir;
+ }
+
+ test('--load-extension defaults to the bundled chromium and adds the launch arguments', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ const config = await resolveCLIConfigForMCP({ loadExtension: [extension] }, emptyEnv);
+ expect(config.browser.browserName).toBe('chromium');
+ expect(config.browser.launchOptions.channel).toBe('chromium');
+ expect(config.browser.extensions).toEqual([extension]);
+ expect(config.browser.launchOptions.args).toEqual(expect.arrayContaining([`--disable-extensions-except=${extension}`, `--load-extension=${extension}`]));
+ expect(config.browser.launchOptions.ignoreDefaultArgs).toEqual(['--disable-extensions']);
+ });
+
+ test('--load-extension can be repeated', async ({}, testInfo) => {
+ const a = await createExtension(testInfo.outputPath('a'));
+ const b = await createExtension(testInfo.outputPath('b'));
+ const config = await resolveCLIConfigForMCP({ loadExtension: [a, b] }, emptyEnv);
+ expect(config.browser.launchOptions.args).toContain(`--load-extension=${a},${b}`);
+ });
+
+ test('--load-extension keeps an explicit chromium browser', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ const config = await resolveCLIConfigForMCP({ loadExtension: [extension], browser: 'chromium' }, emptyEnv);
+ expect(config.browser.launchOptions.channel).toBe('chrome-for-testing');
+ });
+
+ test('--load-extension with browserName chromium and no channel picks the full build over the headless shell', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ const configFile = testInfo.outputPath('config.json');
+ await fs.promises.writeFile(configFile, JSON.stringify({ browser: { browserName: 'chromium' } }));
+ const config = await resolveCLIConfigForMCP({ config: configFile, loadExtension: [extension] }, emptyEnv);
+ expect(config.browser.launchOptions.channel).toBe('chromium');
+ });
+
+ test('--load-extension is rejected with chrome', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ await expect(resolveCLIConfigForMCP({ loadExtension: [extension], browser: 'chrome' }, emptyEnv))
+ .rejects.toThrow('Google Chrome does not load unpacked extensions, use --browser=chromium instead of chrome.');
+ });
+
+ test('--load-extension is rejected in isolated mode', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ await expect(resolveCLIConfigForMCP({ loadExtension: [extension], isolated: true }, emptyEnv))
+ .rejects.toThrow('Extensions only load into a persistent browser profile');
+ });
+
+ test('--load-extension is rejected with firefox', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ await expect(resolveCLIConfigForMCP({ loadExtension: [extension], browser: 'firefox' }, emptyEnv))
+ .rejects.toThrow('Extensions are only supported in Chromium-based browsers');
+ });
+
+ test('--load-extension rejects a missing directory', async ({}, testInfo) => {
+ await expect(resolveCLIConfigForMCP({ loadExtension: [testInfo.outputPath('missing')] }, emptyEnv))
+ .rejects.toThrow('Extension directory does not exist');
+ });
+
+ test('--load-extension rejects a directory without a manifest', async ({}, testInfo) => {
+ const dir = testInfo.outputPath('no-manifest');
+ await fs.promises.mkdir(dir, { recursive: true });
+ await expect(resolveCLIConfigForMCP({ loadExtension: [dir] }, emptyEnv))
+ .rejects.toThrow('Extension directory has no manifest.json');
+ });
+
+ test('PLAYWRIGHT_MCP_LOAD_EXTENSION resolves against cwd', async ({}, testInfo) => {
+ const extension = await createExtension(testInfo.outputPath('ext'));
+ const config = await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_LOAD_EXTENSION: path.relative(process.cwd(), extension) });
+ expect(config.browser.extensions).toEqual([extension]);
+ });
+
+ test('config file extensions resolve against the config directory', async ({}, testInfo) => {
+ await createExtension(testInfo.outputPath('ext'));
+ const configFile = testInfo.outputPath('config.json');
+ await fs.promises.writeFile(configFile, JSON.stringify({ browser: { extensions: ['./ext'] } }));
+ const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv);
+ expect(config.browser.extensions).toEqual([testInfo.outputPath('ext')]);
+ expect(config.browser.launchOptions.channel).toBe('chromium');
+ });
+});
+
// ---------------------------------------------------------------------------
// Shared behavior — validation
// ---------------------------------------------------------------------------
@@ -673,3 +823,52 @@ test.describe('resolveCLIConfigForCLI - extension', () => {
expect(config.browser.isolated).toBe(false);
});
});
+
+test.describe('resolveCLIConfigForCLI - launch options', () => {
+ test('daemon options map onto the config', async ({}, testInfo) => {
+ const initScript = testInfo.outputPath('init.js');
+ await fs.promises.writeFile(initScript, 'window.injected = true;');
+ const extension = testInfo.outputPath('ext');
+ await fs.promises.mkdir(extension, { recursive: true });
+ await fs.promises.writeFile(path.join(extension, 'manifest.json'), '{}');
+ const config = await resolveCLI(testInfo.outputPath('profiles'), 'default', {
+ proxyServer: 'http://user:pass@proxy.example.com:3128',
+ proxyBypass: '.local',
+ ignoreHttpsErrors: true,
+ userAgent: 'Agent/1.0',
+ executablePath: '/path/to/browser',
+ initScript: [initScript],
+ loadExtension: [extension],
+ storageState: 'state.json',
+ allowedOrigins: ['https://example.com', 'http://localhost:*'],
+ blockedOrigins: ['https://ads.example.com'],
+ }) as any;
+ const proxy = { server: 'http://proxy.example.com:3128', username: 'user', password: 'pass', bypass: '.local' };
+ expect(config.browser.launchOptions.proxy).toEqual(proxy);
+ expect(config.browser.contextOptions.proxy).toEqual(proxy);
+ expect(config.browser.contextOptions.ignoreHTTPSErrors).toBe(true);
+ expect(config.browser.contextOptions.userAgent).toBe('Agent/1.0');
+ expect(config.browser.contextOptions.storageState).toBe('state.json');
+ expect(config.browser.launchOptions.executablePath).toBe('/path/to/browser');
+ expect(config.browser.initScript).toEqual([initScript]);
+ expect(config.browser.extensions).toEqual([extension]);
+ expect(config.browser.launchOptions.channel).toBe('chromium');
+ expect(config.network.allowedOrigins).toEqual(['https://example.com', 'http://localhost:*']);
+ expect(config.network.blockedOrigins).toEqual(['https://ads.example.com']);
+ });
+
+ test('--load-extension uses the persistent session profile', async ({}, testInfo) => {
+ const extension = testInfo.outputPath('ext');
+ await fs.promises.mkdir(extension, { recursive: true });
+ await fs.promises.writeFile(path.join(extension, 'manifest.json'), '{}');
+ const profilesDir = testInfo.outputPath('profiles');
+ const config = await resolveCLI(profilesDir, 'default', { loadExtension: [extension] });
+ expect(config.browser.isolated).toBe(false);
+ expect(config.browser.userDataDir).toBe(path.resolve(profilesDir, 'ud-default-chromium'));
+ });
+
+ test('HTTPS_PROXY from the environment applies to the CLI', async ({}, testInfo) => {
+ const config = await resolveCLIConfigForCLI(testInfo.outputPath('profiles'), 'default', {}, { HTTPS_PROXY: 'http://proxy.example.com:8080' });
+ expect(config.browser.launchOptions.proxy).toEqual({ server: 'http://proxy.example.com:8080' });
+ });
+});
diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts
index 9e410e3c796e8..04ca67e21523a 100644
--- a/tests/mcp/launch.spec.ts
+++ b/tests/mcp/launch.spec.ts
@@ -15,6 +15,7 @@
*/
import fs from 'fs';
+import path from 'path';
import { chromium } from 'playwright';
@@ -265,6 +266,66 @@ test('--proxy-server overrides contextOptions.proxy from config file', {
});
});
+test('--proxy-server sends credentials embedded in the URL', async ({ startClient, server }) => {
+ server.setRoute('/target.html', (req, res) => {
+ const auth = req.headers['proxy-authorization'];
+ if (!auth) {
+ res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="proxy"' });
+ res.end();
+ return;
+ }
+ const [user, password] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
+ res.end(`Proxy user ${user} password ${password}`);
+ });
+ const { client } = await startClient({
+ args: [`--proxy-server=http://alice:s3cret@${server.HOST}`],
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: 'http://non-existent.com/target.html' },
+ })).toHaveResponse({
+ page: expect.stringContaining('Proxy user alice password s3cret'),
+ });
+});
+
+test('HTTPS_PROXY from the environment routes browser traffic through the proxy', async ({ startClient, server }) => {
+ server.setRoute('/target.html', (_req, res) => {
+ res.end('Served by the environment proxy');
+ });
+ const { client } = await startClient({
+ env: { HTTPS_PROXY: `http://${server.HOST}` },
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: 'http://non-existent.com/target.html' },
+ })).toHaveResponse({
+ page: expect.stringContaining('Served by the environment proxy'),
+ });
+});
+
+test('--load-extension loads an unpacked extension', async ({ startClient, server, mcpBrowser }, testInfo) => {
+ test.skip(mcpBrowser !== 'chrome' && mcpBrowser !== 'chromium', 'Unpacked extensions are a Chromium feature.');
+ const extension = testInfo.outputPath('ext');
+ await fs.promises.mkdir(extension, { recursive: true });
+ await fs.promises.writeFile(path.join(extension, 'manifest.json'), JSON.stringify({
+ name: 'Marker extension',
+ version: '1.0',
+ manifest_version: 3,
+ content_scripts: [{ matches: [''], js: ['content.js'], run_at: 'document_start' }],
+ }));
+ await fs.promises.writeFile(path.join(extension, 'content.js'), `document.documentElement.setAttribute('data-extension', 'loaded');`);
+ server.setContent('/', ``, 'text/html');
+ const { client } = await startClient({
+ args: ['--browser=chromium', `--load-extension=${extension}`],
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toHaveResponse({
+ snapshot: expect.stringContaining('Extension: loaded'),
+ });
+});
+
test('proper launch error message for broken browser and persistent context', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1305' }
}, async ({ startClient, server, mcpBrowser }, testInfo) => {