Files
AI/참고/playwright-main/tests/mcp/fixtures.ts
2026-05-12 19:40:31 +09:00

357 lines
12 KiB
TypeScript

/**
* 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 fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from '../config/testserver';
import { serverFixtures } from '../config/serverFixtures';
import { tools } from '../../packages/playwright-core/lib/coreBundle';
import { commonFixtures } from '../config/commonFixtures';
import { inheritAndCleanEnv } from '../config/utils';
import type { CommonFixtures, CommonWorkerFixtures } from '../config/commonFixtures';
import type { Config } from '../../packages/playwright-core/src/tools/mcp/config.d';
import type { BrowserContext } from 'playwright';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Stream } from 'stream';
import type { ServerFixtures, ServerWorkerOptions } from '../config/serverFixtures';
export const { parseResponse } = tools;
export type TestOptions = {
mcpArgs: string[] | undefined;
mcpBrowser: string | undefined;
mcpBrowserNormalized: string | undefined;
mcpCaps: string[] | undefined;
mcpServerType: 'mcp' | 'test-mcp';
};
type CDPServer = {
endpoint: string;
start: () => Promise<BrowserContext>;
};
export type StartClient = (options?: {
clientName?: string,
args?: string[],
omitArgs?: string[],
cwd?: string,
config?: Config | string,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
env?: NodeJS.ProcessEnv,
noTimeoutForTest?: boolean,
}) => Promise<{ client: Client, stderr: () => string }>;
type TestFixtures = {
client: Client;
startClient: StartClient;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
};
type WorkerFixtures = {
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const serverTest = baseTest
.extend<CommonFixtures, CommonWorkerFixtures>(commonFixtures)
.extend<ServerFixtures, ServerWorkerOptions>(serverFixtures);
export const test = serverTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
mcpArgs: [undefined, { option: true }],
mcpCaps: [undefined, { option: true }],
client: async ({ startClient }, use) => {
const { client } = await startClient();
await use(client);
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpArgs, mcpServerType, mcpCaps }, use, testInfo) => {
const configDir = path.dirname(test.info().config.configFile!);
const clients: Client[] = [];
await use(async options => {
let args: string[] = mcpArgs ?? [];
if (mcpHeadless)
args.push('--headless');
if (mcpCaps?.length)
args.push(`--caps=${mcpCaps.join(',')}`);
if (mcpServerType === 'test-mcp') {
if (!options?.args?.some(arg => arg.startsWith('--config')))
args.push(`--config=${test.info().outputPath()}`);
} else {
if (mcpBrowser)
args.push(`--browser=${mcpBrowser}`);
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
if (typeof options.config === 'object')
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
else if (typeof options.config === 'string')
await fs.promises.writeFile(configFile, options.config.trim());
args.push(`--config=${path.relative(configDir, configFile)}`);
}
if (!options?.noTimeoutForTest)
args.push('--timeout-action=10000');
}
if (options?.args)
args.push(...options.args);
if (options?.omitArgs)
args = args.filter(arg => !options.omitArgs?.includes(arg));
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
if (options.rootsResponseDelay)
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
return {
roots: options.roots,
};
});
}
const env = inheritAndCleanEnv({
PW_TMPDIR_FOR_TEST: testInfo.outputPath('tmp'),
...options?.env
});
const { transport, stderr } = await createTransport(mcpServerType, { args, env, cwd: options?.cwd || test.info().outputPath() });
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWDEBUGIMPL)
process.stderr.write(data);
stderrBuffer += data.toString();
});
clients.push(client);
await client.connect(transport);
await client.ping();
return { client, stderr: () => stderrBuffer };
});
await Promise.all(clients.map(client => client.close()));
},
wsEndpoint: async ({ }, use) => {
const browserServer = await chromium.launchServer();
await use(browserServer.wsEndpoint());
await browserServer.close();
},
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
let browserContext: BrowserContext | undefined;
const port = 9100 + testInfo.workerIndex;
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
if (browserContext && browserContext.browser()?.isConnected())
throw new Error('CDP server already exists');
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
channel: mcpBrowser,
headless: true,
args: [
`--remote-debugging-port=${port}`,
],
});
return browserContext;
}
});
await browserContext?.close().catch(() => {});
},
mcpHeadless: async ({ headless }, use) => {
await use(headless);
},
server: async ({ server }, use) => {
server.setContent('/favicon.ico', '', 'image/x-icon');
server.setContent('/', ``, 'text/html');
server.setContent('/hello-world', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
await use(server);
},
mcpBrowser: ['chrome', { option: true }],
mcpBrowserNormalized: async ({ mcpBrowser }, use) => {
const normalized = mcpBrowser?.replace(/chromium/, 'chrome-for-testing');
await use(normalized);
},
mcpServerType: ['mcp', { option: true }],
});
async function createTransport(mcpServerType: TestOptions['mcpServerType'], options: { args: string[], env: NodeJS.ProcessEnv, cwd: string }): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
const profilesDir = test.info().outputPath('ms-playwright');
const transport = new StdioClientTransport({
command: 'node',
args: [...(mcpServerType === 'test-mcp' ? testMcpServerPath : mcpServerPath), ...options.args],
cwd: options.cwd,
stderr: 'pipe',
env: {
...options.env,
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
},
});
return {
transport,
stderr: transport.stderr!,
};
}
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({
toHaveResponse(response: Response, object: any) {
const parsed = tools.parseResponse(response, test.info().outputPath());
const text = parsed.text;
const isNot = this.isNot;
const keys = Object.keys(object);
for (const key of Object.keys(parsed)) {
if (!keys.includes(key))
delete parsed[key];
}
try {
if (isNot) {
expect(parsed).not.toEqual(expect.objectContaining(object));
} else {
expect(parsed).toEqual(expect.objectContaining(object));
if (parsed.isError && !object.isError)
throw new Error('Response is an error, but expected is not');
}
} catch (e) {
return {
pass: isNot,
message: () => e.message + '\n\nResponse text:\n' + text,
};
}
return {
pass: !isNot,
message: () => ``,
};
},
toHaveTextResponse(response: Response, value: any) {
const text = response.content[0].text
.replace(/\[id=[^\]]+\]/g, '[id=<ID>]')
.replace(/\([\d\.]+m?s\)/g, '(XXms)')
.replace(/[✓] /g, 'ok');
const isNot = this.isNot;
try {
if (isNot)
expect(text).not.toEqual(value);
else
expect(text).toEqual(value);
} catch (e) {
return {
pass: isNot,
message: () => e.message,
};
}
return {
pass: !isNot,
message: () => ``,
};
},
});
export const mcpServerPath = [require.resolve('../../packages/playwright-core/lib/entry/mcp.js')];
export const testMcpServerPath = [require.resolve('../../packages/playwright-test/cli.js'), 'run-test-mcp-server'];
type Files = { [key: string]: string | Buffer };
export async function writeFiles(files: Files, options?: { update?: boolean }) {
const baseDir = test.info().outputPath();
if (!options?.update && !Object.keys(files).some(name => name.includes('package.json'))) {
files = {
...files,
'package.json': `{ "name": "test-project" }`,
};
}
if (!options?.update && !Object.keys(files).some(name => name.includes('tsconfig.json') || name.includes('jsconfig.json'))) {
files = {
...files,
'tsconfig.json': `{}`,
};
}
await Promise.all(Object.keys(files).map(async name => {
const fullName = path.join(baseDir, name);
if (files[name] === undefined)
return;
await fs.promises.mkdir(path.dirname(fullName), { recursive: true });
await fs.promises.writeFile(fullName, files[name]);
}));
return baseDir;
}
export async function prepareDebugTest(startClient: StartClient, testFile?: string, clientArgs?: Parameters<StartClient>[0]) {
await writeFiles({
'a.test.ts': testFile || `
import { test, expect } from '@playwright/test';
test('fail', async ({ page }) => {
await page.setContent('<button>Submit</button>');
await expect(page.getByRole('button', { name: 'Missing' })).toBeVisible({ timeout: 1000 });
});
`
});
const { client } = await startClient(clientArgs);
const listResult = await client.callTool({
name: 'test_list',
});
const [, id] = listResult.content[0].text.match(/\[id=([^\]]+)\]/);
return { client, id };
}
export function formatLog(stderr: string) {
const lines = stderr.split('\n').filter(l => l.startsWith('pw:mcp:test')).map(l => l.replace(/^pw:mcp:test\s+/, ''));
const object = {};
for (const line of lines)
object[line] = (object[line] || 0) + 1;
return object;
}
export async function consoleEntries(response: any) {
const file = response.events?.match(/New console entries: (.+\.log)(#L\d+)?/)?.[1];
return await fs.promises.readFile(test.info().outputPath(file), 'utf-8');
}