/** * 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 { execSync, spawn } from 'child_process'; import crypto from 'crypto'; import os from 'os'; import path from 'path'; import { isKnownChannel, listChannelSessions } from './channelSessions'; import { JsonOutput, TextOutput } from './output'; import { clientKey, createClientInfo, explicitSessionName, Registry, resolveSessionName } from './registry'; import { Session } from './session'; import { libPath } from '../../package'; import { serverRegistry } from '../../serverRegistry'; import { minimist } from './minimist'; import { processExit } from '../../bootstrap'; import type { ListData, ListedBrowser, Output } from './output'; import type { ClientInfo, SessionFile } from './registry'; import type { MinimistArgs } from './minimist'; type GlobalOptions = { help?: boolean; json?: boolean; raw?: boolean; session?: string; version?: boolean; }; type AttachOptions = { config?: string; cdp?: string; endpoint?: string; extension?: boolean | string; }; type OpenOptions = { browser?: string; config?: string; device?: string; headed?: boolean; mobile?: boolean; persistent?: boolean; profile?: string; }; const globalOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions))[] = [ 'json', 'raw', 'session', ]; const booleanOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions & { all?: boolean, g?: boolean }))[] = [ 'all', 'g', 'help', 'json', 'raw', 'version', ]; export async function program(options?: { embedderVersion?: string}) { const clientInfo = createClientInfo(); const help = require(libPath('tools', 'cli-client', 'help.json')); const argv = process.argv.slice(2); const boolean = [...help.booleanOptions, ...booleanOptions]; const args: MinimistArgs = minimist(argv, { boolean, string: ['_'] }); // Normalize -s alias to --session if (args.s) { args.session = args.s; delete args.s; } // Normalize -g alias to --global if (args.g) { args.global = true; delete args.g; } const output: Output = args.json ? new JsonOutput() : new TextOutput(); const commandName = args._?.[0]; if (args.version || args.v) { output.version(options?.embedderVersion ?? clientInfo.version); // eslint-disable-next-line no-restricted-syntax processExit(0); } const command = commandName && help.commands[commandName]; if (args.help || args.h || !commandName) { if (command) { output.help(command.help); } else { const lines = ['playwright-cli - run playwright mcp commands from terminal']; if (process.env.CLAUDECODE || process.env.COPILOT_CLI) lines.push(`Agent skill: ${path.relative(process.cwd(), libPath('tools', 'skills', 'playwright-cli', 'SKILL.md'))}`); lines.push(help.global); output.help(lines.join('\n\n')); } // eslint-disable-next-line no-restricted-syntax processExit(0); } if (!command) output.errorUnknownCommand(commandName, help.global); validateFlags(args, command, output); validateArgs(args, command, output); const registry = await Registry.load(); const sessionName = resolveSessionName(args.session as string); switch (commandName) { case 'list': { const data = await collectList(registry, clientInfo, !!args.all); output.list(data); return; } case 'close-all': { const entries = registry.entries(clientInfo); const closed: string[] = []; for (const entry of entries) { await new Session(entry).stop(); closed.push(entry.config.name); } output.closeAll(closed); return; } case 'delete-data': { const entry = registry.entry(clientInfo, sessionName); if (!entry) { output.deleteData(sessionName, { existed: false, deletedUserDataDir: false }); return; } const result = await new Session(entry).deleteData(); output.deleteData(sessionName, result); return; } case 'kill-all': { const pids = await killAllDaemons(); output.killAll(pids); return; } case 'open': { const { pid } = await startSession(sessionName, registry, clientInfo, args, 'open'); const newEntry = await registry.loadEntry(clientInfo, sessionName); const params = args._.slice(1); const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ['goto', ...(params.length ? params : ['about:blank'])] }, output); output.open(sessionName, pid, toolText); return; } case 'attach': { const attachTarget = args._[1] as string | undefined; const targetCount = (attachTarget ? 1 : 0) + (args.cdp ? 1 : 0) + (args.endpoint ? 1 : 0) + (args.extension ? 1 : 0); if (targetCount > 1) output.errorAttachConflict(); if (attachTarget) args.endpoint = attachTarget; const extensionChannel = typeof args.extension === 'string' ? args.extension : undefined; if (extensionChannel) { args.browser = extensionChannel; args.extension = true; } const cdpChannel = typeof args.cdp === 'string' && isKnownChannel(args.cdp) ? args.cdp : undefined; const targetName = attachTarget ?? cdpChannel ?? extensionChannel ?? args.endpoint as string ?? args.cdp as string; if (!targetName) output.errorAttachNoTarget(); const attachSessionName = explicitSessionName(args.session as string) ?? attachTarget ?? cdpChannel ?? extensionChannel ?? sessionName; args.session = attachSessionName; const { pid } = await startSession(attachSessionName, registry, clientInfo, args, 'attach'); const newEntry = await registry.loadEntry(clientInfo, attachSessionName); const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ['snapshot'], filename: '' }, output); output.attach(attachSessionName, pid, targetName, toolText); return; } case 'close': { const closeEntry = registry.entry(clientInfo, sessionName); const { wasOpen } = closeEntry ? await new Session(closeEntry).stop() : { wasOpen: false }; output.close(sessionName, wasOpen); return; } case 'detach': { const detachEntry = registry.entry(clientInfo, sessionName); if (detachEntry && !detachEntry.config.attached) output.errorDetachNotAttached(sessionName); const { wasOpen } = detachEntry ? await new Session(detachEntry).stop() : { wasOpen: false }; output.detach(sessionName, wasOpen); return; } case 'install': if (args.global && !args.skills) output.errorInstallGlobalRequiresSkills(); await runInitWorkspace(args, output); output.installed(); return; case 'install-browser': await installBrowser(); output.installed(); return; case 'show': { const daemonScript = libPath('entry', 'dashboardApp.js'); const daemonArgs = [ daemonScript, `--workspaceDir=${clientInfo.workspaceDir ?? ''}`, ]; // Only pass --sessionName when the user explicitly requested a session // (via -s/--session or PLAYWRIGHT_CLI_SESSION). Bare `playwright cli show` // opens the dashboard generically, with no specific session to reveal, // so the daemon should ack as soon as it's ready rather than waiting for // a reveal that was never asked for. const explicit = explicitSessionName(args.session as string); if (explicit) daemonArgs.push(`--sessionName=${explicit}`); if (args.port !== undefined) daemonArgs.push(`--port=${args.port}`); if (args.host !== undefined) daemonArgs.push(`--host=${args.host as string}`); if (args.kill) { daemonArgs.push(`--kill`); const child = spawn(process.execPath, daemonArgs, { stdio: 'ignore' }); await new Promise(resolve => child.on('exit', () => resolve())); return; } if (args.annotate) { const entry = registry.entry(clientInfo, sessionName); if (!entry) output.errorBrowserNotOpenForTool(sessionName); args.raw = true; const text = await runInSession(entry, clientInfo, args, output); output.toolResult(text); return; } const foreground = args.port !== undefined; const child = spawn(process.execPath, daemonArgs, { detached: !foreground, stdio: foreground ? 'inherit' : ['pipe', 'pipe', 'ignore'], windowsHide: true, }); if (foreground) { await new Promise(resolve => child.on('exit', () => resolve())); return; } const timer = setTimeout(() => child.stdin!.destroy(), 60_000); child.unref(); let daemonPid: number; try { await new Promise((resolve, reject) => { let outLog = ''; child.stdout!.on('data', data => { outLog += data.toString(); const match = outLog.match(/Dashboard is running pid=(\d+)/); if (match) { daemonPid = Number(match[1]); resolve(); } }); child.once('exit', (code, signal) => reject(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? '\n' + outLog : ''}`))); }); } finally { clearTimeout(timer); child.removeAllListeners('exit'); child.stdin!.destroy(); child.stdout!.destroy(); } output.show(sessionName, daemonPid!); return; } default: { const entry = registry.entry(clientInfo, sessionName); if (!entry) output.errorBrowserNotOpenForTool(sessionName); if (command.raw) args.raw = true; const text = await runInSession(entry, clientInfo, args, output); output.toolResult(text); } } } async function startSession(sessionName: string, registry: Registry, clientInfo: ClientInfo, args: MinimistArgs, mode: 'open' | 'attach') { const entry = registry.entry(clientInfo, sessionName); if (entry) await new Session(entry).stop(); return await Session.startDaemon(clientInfo, args, mode); } async function runInSession(entry: SessionFile, clientInfo: ClientInfo, args: MinimistArgs, output: Output): Promise { const raw = !!args.raw; for (const globalOption of globalOptions) delete args[globalOption]; const session = new Session(entry); const result = await session.run(clientInfo, args, { raw, json: output.json }); if (result.isError) process.exitCode = 1; return result.text; } // Used by `open` / `attach` after `startSession`: if the implicit goto/snapshot // fails post-spawn (e.g. tool runtime error), stop the freshly-spawned daemon // so we don't strand a detached browser. Pre-spawn arg validation lives in // `validateArgs`; this is a defense-in-depth backstop for runtime errors. async function runInSessionOrStop(entry: SessionFile, clientInfo: ClientInfo, args: MinimistArgs, output: Output): Promise { try { return await runInSession(entry, clientInfo, args, output); } catch (e) { await new Session(entry).stop().catch(() => {}); throw e; } } async function runInitWorkspace(args: MinimistArgs, output: Output) { const cliPath = libPath('entry', 'cliDaemon.js'); const daemonArgs: string[] = [ cliPath, '--init-workspace', ...(args.skills ? [args.global ? '--init-skills-global' : '--init-skills', String(args.skills)] : []), ]; await new Promise((resolve, reject) => { const child = spawn(process.execPath, daemonArgs, { stdio: output.installStdio(), cwd: process.cwd(), }); child.on('close', code => { if (code === 0) resolve(); else reject(new Error(`Workspace initialization failed with exit code ${code}`)); }); }); } async function installBrowser() { const argv = process.argv.map(arg => arg === 'install-browser' ? 'install' : arg); const { libCli } = require('../../coreBundle.js') as typeof import('../../coreBundle'); const { program } = require('../../utilsBundle.js') as typeof import('../../utilsBundle'); if (!program.version()) libCli.decorateProgram(program); program.parse(argv); } const daemonProcessPatterns = ['run-mcp-server', 'run-cli-server', 'cli-daemon', 'cliDaemon.js', 'dashboardApp.js']; async function killAllDaemons(): Promise { const platform = os.platform(); const pidFilterEnv = process.env.PWTEST_KILL_ALL_PID_FILTER_FOR_TEST; const pidFilter = pidFilterEnv ? new Set(pidFilterEnv.split(',').map(p => parseInt(p, 10)).filter(n => !isNaN(n))) : undefined; const killed: number[] = []; try { if (platform === 'win32') { const clauses = [`(${daemonProcessPatterns.map(p => `$_.CommandLine -like '*${p}*'`).join(' -or ')})`]; if (pidFilter) clauses.push(`(${[...pidFilter].map(p => `$_.ProcessId -eq ${p}`).join(' -or ')})`); const whereClause = clauses.join(' -and '); const result = execSync( `powershell -NoProfile -NonInteractive -Command ` + `"Get-CimInstance Win32_Process ` + `| Where-Object { ${whereClause} } ` + `| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`, { encoding: 'utf-8' } ); const pids = result.split('\n') .map(line => line.trim()) .filter(line => /^\d+$/.test(line)); for (const pid of pids) killed.push(parseInt(pid, 10)); } else { const result = execSync('ps auxww', { encoding: 'utf-8' }); const lines = result.split('\n'); for (const line of lines) { if (daemonProcessPatterns.some(p => line.includes(p))) { const parts = line.trim().split(/\s+/); const pid = parts[1]; if (pid && /^\d+$/.test(pid)) { const numericPid = parseInt(pid, 10); if (pidFilter && !pidFilter.has(numericPid)) continue; try { process.kill(numericPid, 'SIGKILL'); killed.push(numericPid); } catch { // Process may have already exited } } } } } } catch (e) { // Silently handle errors - no processes to kill is fine } return killed; } async function collectList(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { const browsers: ListedBrowser[] = []; const entries = registry.entryMap(); // List early to GC. const serverEntries = await serverRegistry.list(); const key = clientKey(clientInfo); for (const [workspaceKey, list] of entries) { if (!all && workspaceKey !== key) continue; for (const entry of list) { const session = new Session(entry); const canConnect = await session.canConnect(); if (!canConnect) { await session.deleteSessionConfig(); continue; } const config = session.config; const channel = config.browser?.launchOptions.channel ?? config.browser?.browserName; browsers.push({ name: session.name, workspace: workspaceKey, status: canConnect ? 'open' : 'closed', browserType: channel, userDataDir: config.browser?.userDataDir ?? null, headed: config.browser ? !config.browser.launchOptions.headless : undefined, persistent: !!config.cli.persistent, attached: !!config.attached, compatible: session.isCompatible(clientInfo), version: config.version, }); } } if (!all) return { all, browsers }; const servers = [...serverEntries.values()].flat(); return { all, browsers, servers, channelSessions: await listChannelSessions() }; } function validateFlags(args: MinimistArgs, command: { flags: Record, help: string }, output: Output) { const unknownFlags: string[] = []; for (const key of Object.keys(args)) { if (key === '_') continue; if ((globalOptions as readonly string[]).includes(key)) continue; if (key in command.flags) continue; // Negated flags like '--no-shell' are parsed as { shell: false }. if (args[key] === false && `no-${key}` in command.flags) continue; unknownFlags.push(key); } if (unknownFlags.length) output.errorUnknownOption(unknownFlags, command.help); } function validateArgs(args: MinimistArgs, command: { args: string[], variadicArg?: boolean, help: string }, output: Output) { const positional = args._.slice(1); if (positional.length > command.args.length && !command.variadicArg) output.errorTooManyArguments(command.args.length, positional.length, command.help); } export function calculateSha1(buffer: Buffer | string): string { const hash = crypto.createHash('sha1'); hash.update(buffer); return hash.digest('hex'); }