Skip to content

Commit a6fb467

Browse files
authored
chore(testRunner): expose pauseAtEnd/pauseOnError (#37923)
1 parent 2c2557c commit a6fb467

File tree

19 files changed

+275
-183
lines changed

19 files changed

+275
-183
lines changed

packages/playwright/src/common/ipc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export type AttachmentPayload = {
8686

8787
export type TestInfoErrorImpl = TestInfoError;
8888

89+
export type TestPausedPayload = {
90+
errors: TestInfoErrorImpl[];
91+
extraData: any;
92+
};
93+
8994
export type TestEndPayload = {
9095
testId: string;
9196
duration: number;

packages/playwright/src/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { setBoxedStackPrefixes, createGuid, currentZone, debugMode, jsonStringif
2222

2323
import { currentTestInfo } from './common/globals';
2424
import { rootTestType } from './common/testType';
25-
import { runBrowserBackendAtEnd } from './mcp/test/browserBackend';
25+
import { runBrowserBackendOnTestPause } from './mcp/test/browserBackend';
2626

2727
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
2828
import type { ContextReuseMode } from './common/config';
@@ -237,7 +237,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
237237
(testInfo as TestInfoImpl)._setDebugMode();
238238

239239
playwright._defaultContextOptions = _combinedContextOptions;
240-
playwright._defaultContextTimeout = (testInfo as TestInfoImpl)._pauseOnError() ? 5000 : actionTimeout || 0;
240+
playwright._defaultContextTimeout = actionTimeout || 0;
241241
playwright._defaultContextNavigationTimeout = navigationTimeout || 0;
242242
await use();
243243
playwright._defaultContextOptions = undefined;
@@ -417,14 +417,14 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
417417
attachConnectedHeaderIfNeeded(testInfo, browserImpl);
418418
if (!_reuseContext) {
419419
const { context, close } = await _contextFactory();
420-
(testInfo as TestInfoImpl)._onDidFinishTestFunctions.unshift(() => runBrowserBackendAtEnd(context, testInfo.errors[0]?.message));
420+
(testInfo as TestInfoImpl)._onDidPauseTestCallback = () => runBrowserBackendOnTestPause(testInfo, context);
421421
await use(context);
422422
await close();
423423
return;
424424
}
425425

426426
const context = await browserImpl._wrapApiCall(() => browserImpl._newContextForReuse(), { internal: true });
427-
(testInfo as TestInfoImpl)._onDidFinishTestFunctions.unshift(() => runBrowserBackendAtEnd(context, testInfo.errors[0]?.message));
427+
(testInfo as TestInfoImpl)._onDidPauseTestCallback = () => runBrowserBackendOnTestPause(testInfo, context);
428428
await use(context);
429429
const closeReason = testInfo.status === 'timedOut' ? 'Test timeout of ' + testInfo.timeout + 'ms exceeded.' : 'Test ended.';
430430
await browserImpl._wrapApiCall(() => browserImpl._disconnectFromReusedContext(closeReason), { internal: true });
@@ -647,7 +647,7 @@ class ArtifactsRecorder {
647647

648648
async willStartTest(testInfo: TestInfoImpl) {
649649
this._testInfo = testInfo;
650-
testInfo._onDidFinishTestFunctions.push(() => this.didFinishTestFunction());
650+
testInfo._onDidFinishTestFunctionCallback = () => this.didFinishTestFunction();
651651

652652
this._screenshotRecorder.fixOrdinal();
653653

packages/playwright/src/isomorphic/testServerConnection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import * as events from './events';
1818

1919
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
20+
import type * as reporterTypes from '../../types/testReporter';
2021

2122
// -- Reuse boundary -- Everything below this line is reused in the vscode extension.
2223

@@ -68,12 +69,14 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
6869
readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
6970
readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>;
7071
readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>;
72+
readonly onTestPaused: events.Event<{ errors: reporterTypes.TestError[] }>;
7173

7274
private _onCloseEmitter = new events.EventEmitter<void>();
7375
private _onReportEmitter = new events.EventEmitter<any>();
7476
private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
7577
private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>();
7678
private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>();
79+
private _onTestPausedEmitter = new events.EventEmitter<{ errors: reporterTypes.TestError[] }>();
7780

7881
private _lastId = 0;
7982
private _transport: TestServerTransport;
@@ -87,6 +90,7 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
8790
this.onStdio = this._onStdioEmitter.event;
8891
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
8992
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
93+
this.onTestPaused = this._onTestPausedEmitter.event;
9094

9195
this._transport = transport;
9296
this._transport.onmessage(data => {
@@ -147,6 +151,8 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
147151
this._onTestFilesChangedEmitter.fire(params);
148152
else if (method === 'loadTraceRequested')
149153
this._onLoadTraceRequestedEmitter.fire(params);
154+
else if (method === 'testPaused')
155+
this._onTestPausedEmitter.fire(params);
150156
}
151157

152158
async initialize(params: Parameters<TestServerInterface['initialize']>[0]): ReturnType<TestServerInterface['initialize']> {

packages/playwright/src/isomorphic/testServerInterface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export interface TestServerInterface {
103103
reuseContext?: boolean;
104104
connectWsEndpoint?: string;
105105
timeout?: number;
106+
pauseOnError?: boolean;
107+
pauseAtEnd?: boolean;
106108
}): Promise<{
107109
status: reporterTypes.FullResult['status'];
108110
}>;
@@ -121,11 +123,13 @@ export interface TestServerInterfaceEvents {
121123
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
122124
onTestFilesChanged: Event<{ testFiles: string[] }>;
123125
onLoadTraceRequested: Event<{ traceUrl: string }>;
126+
onTestPaused: Event<{ errors: reporterTypes.TestError[] }>;
124127
}
125128

126129
export interface TestServerInterfaceEventEmitters {
127130
dispatchEvent(event: 'report', params: ReportEntry): void;
128131
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void;
129132
dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void;
130133
dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void;
134+
dispatchEvent(event: 'testPaused', params: { errors: reporterTypes.TestError[] }): void;
131135
}

packages/playwright/src/mcp/sdk/mdb.ts

Lines changed: 17 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import { debug } from 'playwright-core/lib/utilsBundle';
1818
import { ManualPromise } from 'playwright-core/lib/utils';
1919

20-
import { defineToolSchema } from './tool';
2120
import * as mcpBundle from './bundle';
2221
import * as mcpServer from './server';
2322
import * as mcpHttp from './http';
@@ -27,7 +26,11 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
2726

2827
const mdbDebug = debug('pw:mcp:mdb');
2928
const errorsDebug = debug('pw:mcp:errors');
30-
const z = mcpBundle.z;
29+
30+
export type MDBPushClientCallback = (mcpUrl: string, introMessage?: string) => Promise<void>;
31+
export type MDBServerBackendFactory = Omit<mcpServer.ServerBackendFactory, 'create'> & {
32+
create: (pushClient: MDBPushClientCallback) => mcpServer.ServerBackend;
33+
};
3134

3235
export class MDBBackend implements mcpServer.ServerBackend {
3336
private _onPauseClient: { client: Client, tools: mcpServer.Tool[], transport: StreamableHTTPClientTransport } | undefined;
@@ -37,8 +40,8 @@ export class MDBBackend implements mcpServer.ServerBackend {
3740
private _progress: mcpServer.CallToolResult['content'] = [];
3841
private _progressCallback: mcpServer.ProgressCallback;
3942

40-
constructor(mainBackend: mcpServer.ServerBackend) {
41-
this._mainBackend = mainBackend;
43+
constructor(mainBackendFactory: MDBServerBackendFactory) {
44+
this._mainBackend = mainBackendFactory.create(this._createOnPauseClient.bind(this));
4245
this._progressCallback = (params: mcpServer.ProgressParams) => {
4346
if (params.message)
4447
this._progress.push({ type: 'text', text: params.message });
@@ -57,11 +60,6 @@ export class MDBBackend implements mcpServer.ServerBackend {
5760
}
5861

5962
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
60-
if (name === pushToolsSchema.name) {
61-
await this._createOnPauseClient(pushToolsSchema.inputSchema.parse(args || {}));
62-
return { content: [{ type: 'text', text: 'Tools pushed' }] };
63-
}
64-
6563
if (this._onPauseClient?.tools.find(tool => tool.name === name)) {
6664
const result = await this._onPauseClient.client.callTool({
6765
name,
@@ -95,16 +93,16 @@ export class MDBBackend implements mcpServer.ServerBackend {
9593
return result;
9694
}
9795

98-
private async _createOnPauseClient(params: { mcpUrl: string, introMessage?: string }) {
96+
private async _createOnPauseClient(mcpUrl: string, introMessage?: string) {
9997
if (this._onPauseClient)
10098
await this._onPauseClient.client.close().catch(errorsDebug);
10199

102-
this._onPauseClient = await this._createClient(params.mcpUrl);
100+
this._onPauseClient = await this._createClient(mcpUrl);
103101

104102
this._interruptPromise?.resolve({
105103
content: [{
106104
type: 'text',
107-
text: params.introMessage || '',
105+
text: introMessage || '',
108106
}],
109107
});
110108
this._interruptPromise = undefined;
@@ -128,100 +126,18 @@ export class MDBBackend implements mcpServer.ServerBackend {
128126
}
129127
}
130128

131-
const pushToolsSchema = defineToolSchema({
132-
name: 'mdb_push_tools',
133-
title: 'Push MCP tools to the tools stack',
134-
description: 'Push MCP tools to the tools stack',
135-
inputSchema: z.object({
136-
mcpUrl: z.string(),
137-
introMessage: z.string().optional(),
138-
}),
139-
type: 'readOnly',
140-
});
141-
142-
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
143-
const mdbBackend = new MDBBackend(backendFactory.create());
144-
// Start HTTP unconditionally.
129+
// TODO: add all options from mcpHttp.startHttpServer.
130+
export async function runMainBackend(backendFactory: MDBServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
131+
const mdbBackend = new MDBBackend(backendFactory);
145132
const factory: mcpServer.ServerBackendFactory = {
146133
...backendFactory,
147134
create: () => mdbBackend
148135
};
149-
const url = await startAsHttp(factory, { port: options?.port || 0 });
150-
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
151-
152-
if (options?.port !== undefined)
153-
return url;
154-
155-
// Start stdio conditionally.
156-
await mcpServer.connect(factory, new mcpBundle.StdioServerTransport(), false);
157-
}
158-
159-
export async function runOnPauseBackendLoop(backend: mcpServer.ServerBackend, introMessage: string) {
160-
const wrappedBackend = new ServerBackendWithCloseListener(backend);
161-
162-
const factory = {
163-
name: 'on-pause-backend',
164-
nameInConfig: 'on-pause-backend',
165-
version: '0.0.0',
166-
create: () => wrappedBackend,
167-
};
168-
169-
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
170-
const url = await mcpHttp.installHttpTransport(httpServer, factory, true);
171-
172-
const client = new mcpBundle.Client({ name: 'Pushing client', version: '0.0.0' });
173-
client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({}));
174-
const transport = new mcpBundle.StreamableHTTPClientTransport(new URL(process.env.PLAYWRIGHT_DEBUGGER_MCP!));
175-
await client.connect(transport);
176-
177-
const pushToolsResult = await client.callTool({
178-
name: pushToolsSchema.name,
179-
arguments: {
180-
mcpUrl: url,
181-
introMessage,
182-
},
183-
});
184-
if (pushToolsResult.isError)
185-
errorsDebug('Failed to push tools', pushToolsResult.content);
186-
await transport.terminateSession();
187-
await client.close();
188-
189-
await wrappedBackend.waitForClosed();
190-
httpServer.close();
191-
}
192-
193-
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
194-
const httpServer = await mcpHttp.startHttpServer(options);
195-
return await mcpHttp.installHttpTransport(httpServer, backendFactory, true);
196-
}
197136

198-
199-
class ServerBackendWithCloseListener implements mcpServer.ServerBackend {
200-
private _backend: mcpServer.ServerBackend;
201-
private _serverClosedPromise = new ManualPromise<void>();
202-
203-
constructor(backend: mcpServer.ServerBackend) {
204-
this._backend = backend;
205-
}
206-
207-
async initialize(server: mcpServer.Server, clientInfo: mcpServer.ClientInfo): Promise<void> {
208-
await this._backend.initialize?.(server, clientInfo);
209-
}
210-
211-
async listTools(): Promise<mcpServer.Tool[]> {
212-
return this._backend.listTools();
213-
}
214-
215-
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments'], progress: mcpServer.ProgressCallback): Promise<mcpServer.CallToolResult> {
216-
return this._backend.callTool(name, args, progress);
137+
if (options?.port !== undefined) {
138+
const httpServer = await mcpHttp.startHttpServer(options);
139+
return await mcpHttp.installHttpTransport(httpServer, factory, true);
217140
}
218141

219-
serverClosed(server: mcpServer.Server) {
220-
this._backend.serverClosed?.(server);
221-
this._serverClosedPromise.resolve();
222-
}
223-
224-
async waitForClosed() {
225-
await this._serverClosedPromise;
226-
}
142+
await mcpServer.connect(factory, new mcpBundle.StdioServerTransport(), false);
227143
}

packages/playwright/src/mcp/test/browserBackend.ts

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,22 @@
1515
*/
1616

1717
import * as mcp from '../sdk/exports';
18-
import { currentTestInfo } from '../../common/globals';
19-
import { stripAnsiEscapes } from '../../util';
2018
import { defaultConfig, FullConfig } from '../browser/config';
2119
import { BrowserServerBackend } from '../browser/browserServerBackend';
2220
import { Tab } from '../browser/tab';
2321

2422
import type * as playwright from '../../../index';
2523
import type { Page } from '../../../../playwright-core/src/client/page';
2624
import type { BrowserContextFactory } from '../browser/browserContextFactory';
27-
import type { ClientInfo } from '../sdk/server';
25+
import type { TestInfo } from '../../../test';
2826

29-
export async function runBrowserBackendAtEnd(context: playwright.BrowserContext, errorMessage?: string) {
30-
const testInfo = currentTestInfo();
31-
if (!testInfo)
32-
return;
33-
34-
const shouldPause = errorMessage ? testInfo?._pauseOnError() : testInfo?._pauseAtEnd();
35-
if (!shouldPause)
36-
return;
27+
export type TestPausedExtraData = {
28+
mcpUrl: string;
29+
contextState: string;
30+
};
3731

32+
export async function runBrowserBackendOnTestPause(testInfo: TestInfo, context: playwright.BrowserContext) {
3833
const lines: string[] = [];
39-
if (errorMessage)
40-
lines.push(`### Paused on error:`, stripAnsiEscapes(errorMessage));
41-
else
42-
lines.push(`### Paused at end of test. ready for interaction`);
4334

4435
for (let i = 0; i < context.pages().length; i++) {
4536
const page = context.pages()[i];
@@ -51,7 +42,7 @@ export async function runBrowserBackendAtEnd(context: playwright.BrowserContext,
5142
`- Page Title: ${await page.title()}`.trim()
5243
);
5344
// Only print console errors when pausing on error, not when everything works as expected.
54-
let console = errorMessage ? await Tab.collectConsoleMessages(page) : [];
45+
let console = testInfo.errors.length ? await Tab.collectConsoleMessages(page) : [];
5546
console = console.filter(msg => !msg.type || msg.type === 'error');
5647
if (console.length) {
5748
lines.push('- Console Messages:');
@@ -66,21 +57,29 @@ export async function runBrowserBackendAtEnd(context: playwright.BrowserContext,
6657
);
6758
}
6859

69-
lines.push('');
70-
if (errorMessage)
71-
lines.push(`### Task`, `Try recovering from the error prior to continuing`);
72-
7360
const config: FullConfig = {
7461
...defaultConfig,
7562
capabilities: ['testing'],
7663
};
7764

78-
await mcp.runOnPauseBackendLoop(new BrowserServerBackend(config, identityFactory(context)), lines.join('\n'));
65+
const factory: mcp.ServerBackendFactory = {
66+
name: 'Playwright',
67+
nameInConfig: 'playwright',
68+
version: '0.0.0',
69+
create: () => new BrowserServerBackend(config, identityFactory(context))
70+
};
71+
const httpServer = await mcp.startHttpServer({ port: 0 });
72+
const mcpUrl = await mcp.installHttpTransport(httpServer, factory, true);
73+
const dispose = async () => {
74+
await new Promise(cb => httpServer.close(cb));
75+
};
76+
const extraData = { mcpUrl, contextState: lines.join('\n') } as TestPausedExtraData;
77+
return { extraData, dispose };
7978
}
8079

8180
function identityFactory(browserContext: playwright.BrowserContext): BrowserContextFactory {
8281
return {
83-
createContext: async (clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) => {
82+
createContext: async (clientInfo: mcp.ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) => {
8483
return {
8584
browserContext,
8685
close: async () => {}

packages/playwright/src/mcp/test/testBackend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export class TestServerBackend implements mcp.ServerBackend {
4040
private _context: TestContext;
4141
private _configOption: string | undefined;
4242

43-
constructor(configOption: string | undefined, options?: { muteConsole?: boolean, headless?: boolean }) {
44-
this._context = new TestContext(options);
43+
constructor(configOption: string | undefined, pushClient: mcp.MDBPushClientCallback, options?: { muteConsole?: boolean, headless?: boolean }) {
44+
this._context = new TestContext(pushClient, options);
4545
this._configOption = configOption;
4646
}
4747

0 commit comments

Comments
 (0)