Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/playwright/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class FullConfigInternal {
globalTimeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0),
grep: takeFirst(userConfig.grep, defaultGrep),
grepInvert: takeFirst(userConfig.grepInvert, null),
maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0),
maxFailures: takeFirst(configCLIOverrides.debug === 'inspector' ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0),
metadata: metadata ?? userConfig.metadata,
preserveOutput: takeFirst(userConfig.preserveOutput, 'always'),
projects: [],
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { ReporterDescription, TestInfoError, TestStatus } from '../../types
import type { SerializedCompilationCache } from '../transform/compilationCache';

export type ConfigCLIOverrides = {
debug?: boolean;
debug?: 'cli' | 'inspector';
failOnFlakyTests?: boolean;
forbidOnly?: boolean;
fullyParallel?: boolean;
Expand Down
15 changes: 10 additions & 5 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringif

import { currentTestInfo } from './common/globals';
import { rootTestType } from './common/testType';
import { createCustomMessageHandler } from './mcp/test/browserBackend';
import { createCustomMessageHandler, handleOnTestFunctionEnd } from './mcp/test/browserBackend';

import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
import type { ContextReuseMode } from './common/config';
Expand Down Expand Up @@ -424,19 +424,24 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
await use(reuse);
}, { scope: 'worker', title: 'context', box: true }],

context: async ({ browser, _reuseContext, _contextFactory }, use, testInfo) => {
context: async ({ browser, _reuseContext, _contextFactory }, use, info) => {
const testInfo = info as TestInfoImpl;
const browserImpl = browser as BrowserImpl;
attachConnectedHeaderIfNeeded(testInfo, browserImpl);
if (!_reuseContext) {
const { context, close } = await _contextFactory();
(testInfo as TestInfoImpl)._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
if (testInfo._configInternal.configCLIOverrides.debug === 'cli')
testInfo._onDidFinishTestFunctionCallbacks.add(() => handleOnTestFunctionEnd(testInfo, context));
await use(context);
await close();
return;
}

const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true });
(testInfo as TestInfoImpl)._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
if (testInfo._configInternal.configCLIOverrides.debug === 'cli')
testInfo._onDidFinishTestFunctionCallbacks.add(() => handleOnTestFunctionEnd(testInfo, context));
await use(context);
const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.';
await browserImpl._wrapApiCall(() => browserImpl._disconnectFromReusedContext(closeReason), { internal: true });
Expand Down Expand Up @@ -700,7 +705,7 @@ class ArtifactsRecorder {

async willStartTest(testInfo: TestInfoImpl) {
this._testInfo = testInfo;
testInfo._onDidFinishTestFunctionCallback = () => this.didFinishTestFunction();
testInfo._onDidFinishTestFunctionCallbacks.add(() => this.didFinishTestFunction());

this._screenshotRecorder.fixOrdinal();

Expand Down
13 changes: 13 additions & 0 deletions packages/playwright/src/mcp/terminal/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,18 @@ const sessionList = declareCommand({
toolParams: () => ({}),
});

const sessionAttach = declareCommand({
name: 'attach',
description: 'Attach an external browser session',
category: 'browsers',
hidden: true,
args: z.object({
socket: z.string().describe('Socket path of the external browser session.'),
}),
toolName: '',
toolParams: ({ socket }) => ({ socket }),
});

const sessionCloseAll = declareCommand({
name: 'close-all',
description: 'Close all browser sessions',
Expand Down Expand Up @@ -962,6 +974,7 @@ const commandsArray: AnyCommandSchema[] = [

// session category
sessionList,
sessionAttach,
sessionCloseAll,
killAll,

Expand Down
36 changes: 32 additions & 4 deletions packages/playwright/src/mcp/terminal/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,45 @@ export async function program() {
console.log(result.text);
return;
}

case 'close':
case 'close': {
const closeEntry = registry.entry(clientInfo, sessionName);
const session = closeEntry ? new Session(clientInfo, closeEntry.config) : undefined;
if (session?.isAttached()) {
await session.deleteSessionConfig();
return;
}
if (!session || !await session.canConnect()) {
console.log(`Browser '${sessionName}' is not open.`);
return;
}
await session.stop();
return;
case 'install':
}
case 'attach': {
if (sessionName === 'default') {
console.log(`Cannot attach 'default' session.`);
return;
}
const sessionConfig: SessionConfig = {
name: sessionName,
version: clientInfo.version,
socketPath: args._[1],
timestamp: Date.now(),
cli: { attached: true },
workspaceDir: clientInfo.workspaceDir,
};
const session = new Session(clientInfo, sessionConfig);
if (!await session.canConnect()) {
console.log(`Cannot connect to '${sessionConfig.socketPath}'.`);
return;
}
await session.writeSessionConfig();
return;
}
case 'install': {
await install(args);
return;
}
case 'show': {
const daemonScript = path.join(__dirname, 'devtoolsApp.js');
const child = spawn(process.execPath, [daemonScript], {
Expand Down Expand Up @@ -289,7 +315,7 @@ function defaultConfigFile(): string {
return path.resolve('.playwright', 'cli.config.json');
}

function sessionConfigFromArgs(clientInfo: ClientInfo, sessionName: string, args: MinimistArgs): SessionConfig {
export function sessionConfigFromArgs(clientInfo: ClientInfo, sessionName: string, args: MinimistArgs): SessionConfig {
let config = args.config ? path.resolve(args.config) : undefined;
try {
if (!config && fs.existsSync(defaultConfigFile()))
Expand Down Expand Up @@ -417,6 +443,8 @@ async function renderSessionStatus(session: Session) {
const config = session.config;
const canConnect = await session.canConnect();
text.push(`- ${session.name}:`);
if (session.isAttached())
text.push(` - attached to external browser`);
text.push(` - status: ${canConnect ? 'open' : 'closed'}`);
if (canConnect && !session.isCompatible())
text.push(` - version: v${config.version} [incompatible please re-open]`);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright/src/mcp/terminal/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type SessionConfig = {
persistent?: boolean;
profile?: string;
config?: string;
attached?: boolean;
};
userDataDirPrefix?: string;
workspaceDir?: string;
Expand Down
28 changes: 20 additions & 8 deletions packages/playwright/src/mcp/terminal/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ export class Session {
this.name = options.name;
}

isAttached() {
return !!this.config.cli.attached;
}

isCompatible(): boolean {
return this._clientInfo.version === this.config.version;
return this.isAttached() || this._clientInfo.version === this.config.version;
}

checkCompatible() {
Expand All @@ -74,6 +78,12 @@ to restart the browser session.`);
}

async stop(quiet: boolean = false): Promise<void> {
if (this.isAttached()) {
if (!quiet)
console.log(`Cannot close attached browser '${this.name}'.`);
return;
}

if (!await this.canConnect()) {
if (!quiet)
console.log(`Browser '${this.name}' is not open.`);
Expand Down Expand Up @@ -199,14 +209,8 @@ to restart the browser session.`);
}

private async _startDaemon(): Promise<net.Socket> {
await fs.promises.mkdir(this._clientInfo.daemonProfilesDir, { recursive: true });
const cliPath = path.join(__dirname, '../../../cli.js');

const sessionConfigFile = this._sessionFile('.session');
this.config.version = this._clientInfo.version;
this.config.timestamp = Date.now();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where did the timestamp go?

await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2));

const sessionConfigFile = await this.writeSessionConfig();
const errLog = this._sessionFile('.err');
const err = fs.openSync(errLog, 'w');

Expand Down Expand Up @@ -296,6 +300,14 @@ to restart the browser session.`);
throw error;
}

async writeSessionConfig() {
const sessionConfigFile = this._sessionFile('.session');
this.config.version = this._clientInfo.version;
await fs.promises.mkdir(path.dirname(sessionConfigFile), { recursive: true });
await fs.promises.writeFile(sessionConfigFile, JSON.stringify(this.config, null, 2));
return sessionConfigFile;
}

async deleteSessionConfig() {
await fs.promises.rm(this._sessionFile('.session')).catch(() => {});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright/src/mcp/test/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@

[backend.ts]
../browser/tools.ts

[browserBackend.ts]
../terminal/daemon.ts
../terminal/program.ts
37 changes: 37 additions & 0 deletions packages/playwright/src/mcp/test/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@
* limitations under the License.
*/

import path from 'path';
import { createGuid } from 'playwright-core/lib/utils';

import * as mcp from '../sdk/exports';
import { defaultConfig } from '../browser/config';
import { BrowserServerBackend } from '../browser/browserServerBackend';
import { Tab } from '../browser/tab';
import { stripAnsiEscapes } from '../../util';
import { identityBrowserContextFactory } from '../browser/browserContextFactory';
import { startMcpDaemonServer } from '../terminal/daemon';
import { sessionConfigFromArgs } from '../terminal/program';
import { createClientInfo } from '../terminal/registry';

import type * as playwright from '../../../index';
import type { Page } from '../../../../playwright-core/src/client/page';
Expand Down Expand Up @@ -115,3 +121,34 @@ async function generatePausedMessage(testInfo: TestInfo, context: playwright.Bro

return lines.join('\n');
}

export async function handleOnTestFunctionEnd(testInfo: TestInfo, context: playwright.BrowserContext) {
const sessionConfig = sessionConfigFromArgs(createClientInfo(), createGuid().slice(0, 8), { _: [] });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer the session name to be readable, not a GUID. maybe the test title?

const socketPath = await startMcpDaemonServer({
...defaultConfig,
outputMode: 'file',
snapshot: { mode: 'full', output: 'file' },
outputDir: path.resolve(process.cwd(), '.playwright-cli'),
sessionConfig,
}, identityBrowserContextFactory(context));

const lines = [''];
if (testInfo.errors.length) {
lines.push(`### Paused on test error`);
for (const error of testInfo.errors)
lines.push(stripAnsiEscapes(error.message || ''));
} else {
lines.push(`### Paused at the end of the test`);
}
lines.push(
`### Debugging Instructions`,
`- Use "playwright-cli --session <name> attach '${socketPath}'" to add a session.`,
`- Use "playwright-cli --session <name>" to explore the page and fix the problem.`,
`- Stop this test run when finished. Restart if needed.`,
);
lines.push('');

/* eslint-disable-next-line no-console */
console.log(lines.join('\n'));
await new Promise(() => {});
}
27 changes: 19 additions & 8 deletions packages/playwright/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ async function mergeReports(reportDir: string | undefined, opts: { [key: string]

function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrides {
const overrides: ConfigCLIOverrides = {
debug: options.debug,
failOnFlakyTests: options.failOnFlakyTests ? true : undefined,
forbidOnly: options.forbidOnly ? true : undefined,
fullyParallel: options.fullyParallel ? true : undefined,
Expand All @@ -309,6 +310,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
runAgents: options.runAgents,
workers: options.workers,
pause: process.env.PWPAUSE ? true : undefined,
use: {},
};

if (options.browser) {
Expand All @@ -324,16 +326,25 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid
});
}

if (options.headed || options.debug || overrides.pause)
overrides.use = { headless: false };
if (!options.ui && options.debug) {
overrides.debug = true;
if (options.headed)
overrides.use.headless = false;
if (options.trace)
overrides.use.trace = options.trace;

if (overrides.debug === 'inspector') {
overrides.use.headless = false;
process.env.PWDEBUG = '1';
}
if (!options.ui && options.trace) {
overrides.use = overrides.use || {};
overrides.use.trace = options.trace;
if (overrides.debug === 'cli') {
overrides.timeout = 0;
overrides.use.actionTimeout = 5000;
}

if (options.ui || options.uiHost || options.uiPort) {
delete overrides.use.trace;
overrides.debug = undefined;
}

if (overrides.tsconfig && !fs.existsSync(overrides.tsconfig))
throw new Error(`--tsconfig "${options.tsconfig}" does not exist`);

Expand Down Expand Up @@ -403,7 +414,7 @@ const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries
const testOptions: [string, { description: string, choices?: string[], preset?: string }][] = [
/* deprecated */ ['--browser <browser>', { description: `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")` }],
['-c, --config <file>', { description: `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"` }],
['--debug', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options` }],
['--debug [mode]', { description: `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --max-failures=1 --headed --workers=1" options`, choices: ['cli', 'inspector'], preset: 'inspector' }],
['--fail-on-flaky-tests', { description: `Fail if any test is flagged as flaky (default: false)` }],
['--forbid-only', { description: `Fail if test.only is called (default: false)` }],
['--fully-parallel', { description: `Run all tests in parallel (default: false)` }],
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright/src/skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: playwright-cli
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
allowed-tools: Bash(playwright-cli:*)
description: Automate browser interactions, test web pages and work with Playwright tests.
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
---

# Browser Automation with playwright-cli
Expand Down Expand Up @@ -250,6 +250,7 @@ playwright-cli close

## Specific tasks

* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
* **Running Playwright code** [references/running-code.md](references/running-code.md)
* **Browser session management** [references/session-management.md](references/session-management.md)
Expand Down
Loading
Loading