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
4 changes: 2 additions & 2 deletions packages/playwright/src/mcp/test/generatorTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ export const setupPage = defineTestTool({
type: 'readOnly',
},

handle: async (context, params) => {
handle: async (context, params, signal) => {
const seed = await context.getOrCreateSeedFile(params.seedFile, params.project);
context.generatorJournal = new GeneratorJournal(context.rootPath, params.plan, seed);
const { output, status } = await context.runSeedTest(seed.file, seed.projectName);
const { output, status } = await context.runSeedTest(seed.file, seed.projectName, signal);
return { content: [{ type: 'text', text: output }], isError: status !== 'paused' };
},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright/src/mcp/test/plannerTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export const setupPage = defineTestTool({
type: 'readOnly',
},

handle: async (context, params) => {
handle: async (context, params, signal) => {
const seed = await context.getOrCreateSeedFile(params.seedFile, params.project);
const { output, status } = await context.runSeedTest(seed.file, seed.projectName);
const { output, status } = await context.runSeedTest(seed.file, seed.projectName, signal);
return { content: [{ type: 'text', text: output }], isError: status !== 'paused' };
},
});
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright/src/mcp/test/testBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ export class TestServerBackend extends EventEmitter implements tools.ServerBacke
this._context = new TestContext(clientInfo, this._configPath, this._options);
}

async callTool(name: string, args: tools.CallToolRequest['params']['arguments']): Promise<tools.CallToolResult> {
async callTool(name: string, args: tools.CallToolRequest['params']['arguments'], signal: AbortSignal): Promise<tools.CallToolResult> {
const tool = testServerBackendTools.find(tool => tool.schema.name === name);
if (!tool)
throw new Error(`Tool not found: ${name}. Available tools: ${testServerBackendTools.map(tool => tool.schema.name).join(', ')}`);
try {
return await tool.handle(this._context!, tool.schema.inputSchema.parse(args || {}));
return await tool.handle(this._context!, tool.schema.inputSchema.parse(args || {}), signal);
} catch (e) {
return { content: [{ type: 'text', text: String(e) }], isError: true };
}
Expand All @@ -83,7 +83,7 @@ function wrapBrowserTool(tool: tools.Tool): TestTool {
...tool.schema,
inputSchema,
},
handle: async (context: TestContext, params: any) => {
handle: async (context: TestContext, params: any, _signal?: AbortSignal) => {
const response = await context.sendMessageToPausedTest({ callTool: { name: tool.schema.name, arguments: params } });
return response.callTool!;
},
Expand Down
35 changes: 31 additions & 4 deletions packages/playwright/src/mcp/test/testContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type TestRunnerAndScreen = {
export class TestContext {
private _clientInfo: tools.ClientInfo;
private _testRunnerAndScreen: TestRunnerAndScreen | undefined;
private _testOpQueue: Promise<void> = Promise.resolve();
readonly computedHeaded: boolean;
private readonly _configLocation: ConfigLocation;
readonly rootPath: string;
Expand All @@ -105,6 +106,12 @@ export class TestContext {
this.computedHeaded = !process.env.CI && !(os.platform() === 'linux' && !process.env.DISPLAY);
}

private _enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = this._testOpQueue.then(fn);
this._testOpQueue = result.then(() => {}, () => {});
return result;
}

existingTestRunner(): testRunner.TestRunner | undefined {
return this._testRunnerAndScreen?.testRunner;
}
Expand Down Expand Up @@ -176,7 +183,7 @@ export class TestContext {
};
}

async runSeedTest(seedFile: string, projectName: string): Promise<{ output: string, status: testRunner.FullResultStatus | 'paused' }> {
async runSeedTest(seedFile: string, projectName: string, signal?: AbortSignal): Promise<{ output: string, status: testRunner.FullResultStatus | 'paused' }> {
const result = await this.runTestsWithGlobalSetupAndPossiblePause({
headed: this.computedHeaded,
locations: ['/' + escapeRegExp(seedFile) + '/'],
Expand All @@ -186,15 +193,19 @@ export class TestContext {
pauseAtEnd: true,
disableConfigReporters: true,
failOnLoadErrors: true,
});
}, signal);
if (result.status === 'passed')
result.output += '\nError: seed test not found.';
else if (result.status !== 'paused')
result.output += '\nError while running the seed test.';
return result;
}

async runTestsWithGlobalSetupAndPossiblePause(params: testRunner.RunTestsParams): Promise<{ output: string, status: testRunner.FullResultStatus | 'paused' }> {
async runTestsWithGlobalSetupAndPossiblePause(params: testRunner.RunTestsParams, signal?: AbortSignal): Promise<{ output: string, status: testRunner.FullResultStatus | 'paused' }> {
return this._enqueue(() => this._runTestsImpl(params, signal));
}

private async _runTestsImpl(params: testRunner.RunTestsParams, signal?: AbortSignal): Promise<{ output: string, status: testRunner.FullResultStatus | 'paused' }> {
const configDir = this._configLocation.configDir;
const testRunnerAndScreen = await this.createTestRunner();
const { testRunner: runner, screen, claimStdio, releaseStdio } = testRunnerAndScreen;
Expand Down Expand Up @@ -222,13 +233,29 @@ export class TestContext {
}
};

const abortPromise: Promise<'interrupted'> = signal
? new Promise(resolve => {
if (signal.aborted)
resolve('interrupted');
else
signal.addEventListener('abort', () => resolve('interrupted'), { once: true });
})
: new Promise<never>(() => {});

try {
const reporter = new MCPListReporter({ configDir, screen, includeTestId: true });
status = await Promise.race([
runner.runTests(reporter, params).then(result => result.status),
testRunnerAndScreen.waitForTestPaused().then(() => 'paused' as const),
abortPromise,
]);

if (status === 'interrupted') {
await runner.stopTests();
await cleanup();
return { output: testRunnerAndScreen.output.join('\n'), status };
}

if (status === 'paused') {
const response = await testRunnerAndScreen.sendMessageToPausedTest!({ request: { initialize: { clientInfo: this._clientInfo } } });
if (response.error)
Expand All @@ -248,7 +275,7 @@ export class TestContext {
}

async close() {
await this._cleanupTestRunner().catch(e => debug('pw:mcp:error')(e));
await this._enqueue(() => this._cleanupTestRunner()).catch(e => debug('pw:mcp:error')(e));
}

async sendMessageToPausedTest(request: BrowserMCPRequest): Promise<BrowserMCPResponse> {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/test/testTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { tools } from 'playwright-core/lib/coreBundle';

export type TestTool<Input extends z.Schema = z.Schema> = {
schema: tools.ToolSchema<Input>;
handle: (context: TestContext, params: z.output<Input>) => Promise<CallToolResult>;
handle: (context: TestContext, params: z.output<Input>, signal?: AbortSignal) => Promise<CallToolResult>;
};

export function defineTestTool<Input extends z.Schema>(tool: TestTool<Input>): TestTool<Input> {
Expand Down
8 changes: 4 additions & 4 deletions packages/playwright/src/mcp/test/testTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ export const runTests = defineTestTool({
type: 'readOnly',
},

handle: async (context, params) => {
handle: async (context, params, signal) => {
const { output } = await context.runTestsWithGlobalSetupAndPossiblePause({
locations: params.locations ?? [],
projects: params.projects,
disableConfigReporters: true,
});
}, signal);
return { content: [{ type: 'text', text: output }] };
},
});
Expand All @@ -71,7 +71,7 @@ export const debugTest = defineTestTool({
type: 'readOnly',
},

handle: async (context, params) => {
handle: async (context, params, signal) => {
const { output, status } = await context.runTestsWithGlobalSetupAndPossiblePause({
headed: context.computedHeaded,
locations: [], // we can make this faster by passing the test's location, so we don't need to scan all tests to find the ID
Expand All @@ -82,7 +82,7 @@ export const debugTest = defineTestTool({
pauseOnError: true,
disableConfigReporters: true,
actionTimeout: 5000,
});
}, signal);
return { content: [{ type: 'text', text: output }], isError: status !== 'paused' && status !== 'passed' };
},
});
38 changes: 38 additions & 0 deletions tests/mcp/test-run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,44 @@ Running 2 tests using 1 worker
2 passed (XXms)`);
});

test('test_run should stop when aborted', async ({ startClient }) => {
await writeFiles({
'slow.test.ts': `
import { test } from '@playwright/test';
test('slow', async () => {
await new Promise(resolve => setTimeout(resolve, 60_000));
});
`,
'fast.test.ts': `
import { test } from '@playwright/test';
test('fast', async () => {});
`,
});

const { client } = await startClient();
const controller = new AbortController();

const runPromise = client.callTool(
{ name: 'test_run', arguments: { locations: ['slow.test.ts'] } },
undefined,
{ signal: controller.signal },
);

await new Promise(resolve => setTimeout(resolve, 2000));
controller.abort();

// Per MCP spec, client can initiate a new call without waiting for the
// aborted one. Start the next run immediately to verify serialization.
const [, response] = await Promise.all([
runPromise.catch(() => {}),
client.callTool({
name: 'test_run',
arguments: { locations: ['fast.test.ts'] },
}),
]);
expect(response.content[0].text).toContain('1 passed');
});

test('test_run should include dependencies', async ({ startClient }) => {
await writeFiles({
'playwright.config.ts': `
Expand Down