Skip to content
Merged
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
17 changes: 10 additions & 7 deletions packages/playwright-core/src/server/debugger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class Debugger extends SdkObject implements InstrumentationListener {
private _pauseAt: PauseAt = {};
private _pausedCallsMetadata = new Map<CallMetadata, { resolve: () => void, sdkObject: SdkObject }>();
private _enabled = false;
private _pauseBeforeInputActions = false; // instead of inside input actions
private _context: BrowserContext;

static Events = {
Expand All @@ -54,7 +55,7 @@ export class Debugger extends SdkObject implements InstrumentationListener {
if (this._muted)
return;
const pauseOnPauseCall = this._enabled && metadata.method === 'pause';
const pauseOnNextStep = !!this._pauseAt.next && shouldPauseBeforeStep(metadata);
const pauseOnNextStep = !!this._pauseAt.next && shouldPauseBeforeStep(metadata, this._pauseBeforeInputActions);
const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location);
if (pauseOnPauseCall || pauseOnNextStep || pauseOnLocation)
await this._pause(sdkObject, metadata);
Expand All @@ -63,9 +64,7 @@ export class Debugger extends SdkObject implements InstrumentationListener {
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._muted)
return;
const pauseOnNextStep = !!this._pauseAt.next;
const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location);
if (pauseOnNextStep || pauseOnLocation)
if (!!this._pauseAt.next && !this._pauseBeforeInputActions)
await this._pause(sdkObject, metadata);
}

Expand Down Expand Up @@ -94,6 +93,10 @@ export class Debugger extends SdkObject implements InstrumentationListener {
this.emit(Debugger.Events.PausedStateChanged);
}

setPauseBeforeInputActions() {
this._pauseBeforeInputActions = true;
}

setPauseAt(at: { next?: boolean, location?: { file: string, line?: number, column?: number } } = {}) {
this._enabled = true;
this._pauseAt = at;
Expand All @@ -114,14 +117,14 @@ export class Debugger extends SdkObject implements InstrumentationListener {
}

function matchesLocation(metadata: CallMetadata, location: { file: string, line?: number, column?: number }): boolean {
return metadata.location?.file === location.file &&
return !!metadata.location?.file.includes(location.file) &&
(location.line === undefined || metadata.location.line === location.line) &&
(location.column === undefined || metadata.location.column === location.column);
}

function shouldPauseBeforeStep(metadata: CallMetadata): boolean {
function shouldPauseBeforeStep(metadata: CallMetadata, includeInputActions: boolean): boolean {
if (metadata.internal)
return false;
const metainfo = methodMetainfo.get(metadata.type + '.' + metadata.method);
return !!metainfo?.pausesBeforeAction;
return !!metainfo?.pausesBeforeAction || (includeInputActions && !!metainfo?.pausesBeforeInput);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class DebuggerDispatcher extends Dispatcher<Debugger, channels.DebuggerCh
this.addObjectListener(Debugger.Events.PausedStateChanged, () => {
this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() });
});
this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() });
}

private _serializePausedDetails(): channels.DebuggerPausedStateChangedEvent['pausedDetails'] {
Expand All @@ -50,6 +51,7 @@ export class DebuggerDispatcher extends Dispatcher<Debugger, channels.DebuggerCh
}

async setPauseAt(params: channels.DebuggerSetPauseAtParams, progress: Progress): Promise<void> {
this._object.setPauseBeforeInputActions();
this._object.setPauseAt(params);
}

Expand Down
20 changes: 12 additions & 8 deletions packages/playwright-core/src/skill/references/playwright-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,28 @@ PLAYWRIGHT_HTML_OPEN=never npm run special-test-command

# Debugging Playwright Tests

To debug a failing test, run it with Playwright as usual, but set `PWPAUSE=cli` environment variable. This command will pause the test at the point of failure, and print the debugging instructions.
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.

**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed.

Once instructions are printed, use `playwright-cli` to explore the page. Debugging instructions include a browser name that should be used in `playwright-cli` to attach to the page under test.
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.

```bash
# Run the test
PLAYWRIGHT_HTML_OPEN=never PWPAUSE=cli npx playwright test
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
# ...
# ... debugging instructions for "tw-abcdef" session ...
# ...

# Explore the page and interact if needed
playwright-cli --session=test open --attach=test-worker-abcdef
playwright-cli --session=test snapshot
playwright-cli --session=test click e14
# Attach to the test
playwright-cli attach tw-abcdef
```

Keep the test running in the background while you explore and look for a fix. After fixing the test, stop the background test run.
Keep the test running in the background while you explore and look for a fix.
The test is paused at the start, so you should step over or pause at a particular location
where the problem is most likely to be.

Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.

After fixing the test, stop the background test run. Rerun to check that test passes.
4 changes: 4 additions & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export class Context {
await this.stopVideoRecording();
}

debugger() {
return this._rawBrowserContext.debugger;
}

tabs(): Tab[] {
return this._tabs;
}
Expand Down
65 changes: 65 additions & 0 deletions packages/playwright-core/src/tools/backend/devtools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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 { z } from '../../mcpBundle';
import { defineTool } from './tool';

const resume = defineTool({
capability: 'devtools',

schema: {
name: 'browser_resume',
title: 'Resume paused script execution',
description: 'Resume script execution after it was paused. When called with step set to true, execution will pause again before the next action.',
inputSchema: z.object({
step: z.boolean().optional().describe('When true, execution will pause again before the next action, allowing step-by-step debugging.'),
location: z.string().optional().describe('Pause execution at a specific <file>:<line>, e.g. "example.spec.ts:42".'),
}),
type: 'action',
},

handle: async (context, params, response) => {
const browserContext = await context.ensureBrowserContext();
const pausedPromise = new Promise<void>(resolve => {
const listener = () => {
if (browserContext.debugger.pausedDetails().length > 0) {
browserContext.debugger.off('pausedstatechanged', listener);
resolve();
}
};
browserContext.debugger.on('pausedstatechanged', listener);
});

let location;
if (params.location) {
const [file, lineStr] = params.location.split(':');
if (lineStr) {
const line = Number(lineStr);
if (isNaN(line))
throw new Error(`Invalid location "${params.location}", expected format is <file>:<line>, e.g. "example.spec.ts:42"`);
location = { file, line };
} else {
location = { file: params.location };
}
}

await browserContext.debugger.setPauseAt({ next: !!params.step, location });
await browserContext.debugger.resume();
await pausedPromise;
},
});

export default [resume];
10 changes: 10 additions & 0 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ export class Response {
}
if (text.length)
addSection('Events', text);

const pausedDetails = this._context.debugger().pausedDetails();
if (pausedDetails.length) {
addSection('Paused', [
...pausedDetails.map(call => `- ${call.title} at ${this._computRelativeTo(call.location.file)}${call.location.line ? ':' + call.location.line : ''}`),
'- Use any tools to explore and interact, resume by calling resume/step-over/pause-at',
]);
}
return sections;
}
}
Expand Down Expand Up @@ -312,6 +320,7 @@ export function parseResponse(response: CallToolResult) {
const snapshot = sections.get('Snapshot');
const events = sections.get('Events');
const modalState = sections.get('Modal state');
const paused = sections.get('Paused');
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
const isError = response.isError;
const attachments = response.content.length > 1 ? response.content.slice(1) : undefined;
Expand All @@ -325,6 +334,7 @@ export function parseResponse(response: CallToolResult) {
snapshot,
events,
modalState,
paused,
isError,
attachments,
text,
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import common from './common';
import config from './config';
import console from './console';
import cookies from './cookies';
import devtools from './devtools';
import dialogs from './dialogs';
import evaluate from './evaluate';
import files from './files';
Expand Down Expand Up @@ -49,6 +50,7 @@ export const browserTools: Tool<any>[] = [
...config,
...console,
...cookies,
...devtools,
...dialogs,
...evaluate,
...files,
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/cli-client/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export class Session {

if (cliArgs['attach']) {
console.log(`### Session \`${sessionName}\` created, attached to \`${cliArgs['attach']}\`.`);
console.log(`Run commands with: playwright --session=${sessionName} <command>`);
console.log(`Run commands with: playwright-cli --session=${sessionName} <command>`);
} else {
console.log(`### Browser \`${sessionName}\` opened with pid ${child.pid}.`);
}
Expand Down
32 changes: 32 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,35 @@ const devtoolsShow = declareCommand({
toolParams: () => ({}),
});

const resume = declareCommand({
name: 'resume',
description: 'Resume the test execution',
category: 'devtools',
args: z.object({}),
toolName: 'browser_resume',
toolParams: ({ step }) => ({ step }),
});

const stepOver = declareCommand({
name: 'step-over',
description: 'Step over the next call in the test',
category: 'devtools',
args: z.object({}),
toolName: 'browser_resume',
toolParams: ({}) => ({ step: true }),
});

const pauseAt = declareCommand({
name: 'pause-at',
description: 'Run the test up to a specific location and pause there',
category: 'devtools',
args: z.object({
location: z.string().describe('Location to pause at. Format is <file>:<line>, e.g. "example.spec.ts:42".'),
}),
toolName: 'browser_resume',
toolParams: ({ location }) => ({ location }),
});

// Sessions

const sessionList = declareCommand({
Expand Down Expand Up @@ -989,6 +1018,9 @@ const commandsArray: AnyCommandSchema[] = [
videoStart,
videoStop,
devtoolsShow,
pauseAt,
resume,
stepOver,

// session category
sessionList,
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export class FullProjectInternal {
snapshotDir: takeFirst(pathResolve(configDir, projectConfig.snapshotDir), pathResolve(configDir, config.snapshotDir), testDir),
testIgnore: takeFirst(projectConfig.testIgnore, config.testIgnore, []),
testMatch: takeFirst(projectConfig.testMatch, config.testMatch, '**/*.@(spec|test).{md,?(c|m)[jt]s?(x)}'),
timeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout),
timeout: takeFirst(configCLIOverrides.debug === 'inspector' ? 0 : undefined, configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout),
use: mergeObjects(config.use, projectConfig.use, configCLIOverrides.use),
dependencies: projectConfig.dependencies || [],
teardown: projectConfig.teardown,
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?: 'inspector' | 'cli';
failOnFlakyTests?: boolean;
forbidOnly?: boolean;
fullyParallel?: boolean;
Expand Down
6 changes: 3 additions & 3 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 { buildErrorContext } from './errorContext';
import { currentTestInfo } from './common/globals';
import { rootTestType } from './common/testType';
import { createCustomMessageHandler, runDaemonForBrowser } from './mcp/test/browserBackend';
import { createCustomMessageHandler, runDaemonForContext } 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 @@ -444,15 +444,15 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
if (!_reuseContext) {
const { context, close } = await _contextFactory();
testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser));
await runDaemonForContext(testInfo, context);
await use(context);
await close();
return;
}

const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true });
testInfo._onCustomMessageCallback = createCustomMessageHandler(testInfo, context);
testInfo._onDidFinishTestFunctionCallbacks.add(() => runDaemonForBrowser(testInfo, browser));
await runDaemonForContext(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
38 changes: 14 additions & 24 deletions packages/playwright/src/mcp/test/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,31 +110,21 @@ async function generatePausedMessage(testInfo: TestInfoImpl, context: playwright
return lines.join('\n');
}

export async function runDaemonForBrowser(testInfo: TestInfoImpl, browser: playwright.Browser): Promise<void> {
if (process.env.PWPAUSE !== 'cli')
return;
export async function runDaemonForContext(testInfo: TestInfoImpl, context: playwright.BrowserContext) {
if (testInfo._configInternal.configCLIOverrides.debug !== 'cli')
return false;

const browserTitle = `test-worker-${createGuid().slice(0, 6)}`;
await (browser as Browser)._register(browserTitle, { workspaceDir: testInfo.project.testDir });

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`,
`- Pick a session name, e.g. "test"`,
`- Run "playwright-cli --session=<name> open --attach=${browserTitle}" to attach to this page`,
`- Use "playwright-cli --session=<name>" to explore the page and fix the problem`,
`- Stop this test run when finished. Restart if needed.`,
``,
);
const sessionName = `tw-${createGuid().slice(0, 6)}`;
await (context.browser() as Browser)._register(sessionName, { workspaceDir: testInfo.project.testDir });

/* eslint-disable-next-line no-console */
console.log(lines.join('\n'));
await new Promise(() => {});
console.log([
`### The test is currently paused at the start`,
``,
`### Debugging Instructions`,
`- Run "playwright-cli attach ${sessionName}" to attach to this test`,
].join('\n'));

await context.debugger.setPauseAt({ next: true });
return true;
}
Loading
Loading