Update test framework: fix run_tests.py to support all test files, add auto-import-check for test files

This commit is contained in:
qiaoxinjiu
2026-05-09 15:11:30 +08:00
parent eb053a347f
commit eaba8328da
21739 changed files with 2236758 additions and 719 deletions

View File

@@ -0,0 +1,33 @@
import type { APIRequestContext } from 'playwright';
import { ToolHandler, ToolContext, ToolResponse } from '../common/types.js';
/**
* Base class for all API-based tools
* Provides common functionality and error handling
*/
export declare abstract class ApiToolBase implements ToolHandler {
protected server: any;
constructor(server: any);
/**
* Main execution method that all tools must implement
*/
abstract execute(args: any, context: ToolContext): Promise<ToolResponse>;
/**
* Ensures an API context is available and returns it
* @param context The tool context containing apiContext
* @returns The apiContext or null if not available
*/
protected ensureApiContext(context: ToolContext): APIRequestContext | null;
/**
* Validates that an API context is available and returns an error response if not
* @param context The tool context
* @returns Either null if apiContext is available, or an error response
*/
protected validateApiContextAvailable(context: ToolContext): ToolResponse | null;
/**
* Safely executes an API operation with proper error handling
* @param context The tool context
* @param operation The async operation to perform
* @returns The tool response
*/
protected safeExecute(context: ToolContext, operation: (apiContext: APIRequestContext) => Promise<ToolResponse>): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,49 @@
import { createErrorResponse } from '../common/types.js';
/**
* Base class for all API-based tools
* Provides common functionality and error handling
*/
export class ApiToolBase {
constructor(server) {
this.server = server;
}
/**
* Ensures an API context is available and returns it
* @param context The tool context containing apiContext
* @returns The apiContext or null if not available
*/
ensureApiContext(context) {
if (!context.apiContext) {
return null;
}
return context.apiContext;
}
/**
* Validates that an API context is available and returns an error response if not
* @param context The tool context
* @returns Either null if apiContext is available, or an error response
*/
validateApiContextAvailable(context) {
if (!this.ensureApiContext(context)) {
return createErrorResponse("API context not initialized");
}
return null;
}
/**
* Safely executes an API operation with proper error handling
* @param context The tool context
* @param operation The async operation to perform
* @returns The tool response
*/
async safeExecute(context, operation) {
const apiError = this.validateApiContextAvailable(context);
if (apiError)
return apiError;
try {
return await operation(context.apiContext);
}
catch (error) {
return createErrorResponse(`API operation failed: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './base.js';
export * from './requests.js';

View File

@@ -0,0 +1,3 @@
export * from './base.js';
export * from './requests.js';
// TODO: Add exports for other API tools as they are implemented

View File

@@ -0,0 +1,61 @@
import { ApiToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Base arguments for all API requests
*/
export interface BaseRequestArgs {
url: string;
token?: string;
headers?: Record<string, string>;
}
/**
* Arguments for requests with body (POST, PUT, PATCH)
*/
export interface RequestWithBodyArgs extends BaseRequestArgs {
value: string | object;
}
/**
* Tool for making GET requests
*/
export declare class GetRequestTool extends ApiToolBase {
/**
* Execute the GET request tool
*/
execute(args: BaseRequestArgs, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for making POST requests
*/
export declare class PostRequestTool extends ApiToolBase {
/**
* Execute the POST request tool
*/
execute(args: RequestWithBodyArgs, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for making PUT requests
*/
export declare class PutRequestTool extends ApiToolBase {
/**
* Execute the PUT request tool
*/
execute(args: RequestWithBodyArgs, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for making PATCH requests
*/
export declare class PatchRequestTool extends ApiToolBase {
/**
* Execute the PATCH request tool
*/
execute(args: RequestWithBodyArgs, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for making DELETE requests
*/
export declare class DeleteRequestTool extends ApiToolBase {
/**
* Execute the DELETE request tool
*/
execute(args: BaseRequestArgs, context: ToolContext): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,252 @@
import { ApiToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
/**
* Helper function to safely parse JSON string or return the value as-is
* @param value The value to parse (can be string or object)
* @returns Parsed JSON object or the original value
*/
function parseJsonSafely(value) {
if (typeof value === 'string') {
try {
return JSON.parse(value);
}
catch (error) {
// Log warning for debugging
console.warn('Failed to parse JSON, using raw string:', error instanceof Error ? error.message : 'Unknown error');
return value;
}
}
return value;
}
/**
* Helper function to build request headers with optional token and custom headers
* @param token Optional Bearer token for authorization
* @param customHeaders Optional custom headers to include
* @param includeContentType Whether to include Content-Type: application/json header
* @returns Merged headers object
*/
function buildHeaders(token, customHeaders, includeContentType = false) {
const headers = {};
if (includeContentType) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (customHeaders) {
// Warn if both token and Authorization header are provided
if (token && customHeaders['Authorization']) {
console.warn('Both token and Authorization header provided. Custom Authorization header will override token.');
}
Object.assign(headers, customHeaders);
}
return headers;
}
/**
* Validate headers are all strings
* @param headers Headers to validate
* @returns Error message if invalid, null if valid
*/
function validateHeaders(headers) {
if (!headers)
return null;
for (const [key, value] of Object.entries(headers)) {
if (typeof value !== 'string') {
return `Header '${key}' must be a string, got ${typeof value}`;
}
}
return null;
}
/**
* Tool for making GET requests
*/
export class GetRequestTool extends ApiToolBase {
/**
* Execute the GET request tool
*/
async execute(args, context) {
return this.safeExecute(context, async (apiContext) => {
// Validate headers
const headerError = validateHeaders(args.headers);
if (headerError) {
return createErrorResponse(headerError);
}
const response = await apiContext.get(args.url, {
headers: buildHeaders(args.token, args.headers)
});
let responseText;
try {
responseText = await response.text();
}
catch (error) {
responseText = "Unable to get response text";
}
return createSuccessResponse([
`GET request to ${args.url}`,
`Status: ${response.status()} ${response.statusText()}`,
`Response: ${responseText.substring(0, 1000)}${responseText.length > 1000 ? '...' : ''}`
]);
});
}
}
/**
* Tool for making POST requests
*/
export class PostRequestTool extends ApiToolBase {
/**
* Execute the POST request tool
*/
async execute(args, context) {
return this.safeExecute(context, async (apiContext) => {
// Validate headers
const headerError = validateHeaders(args.headers);
if (headerError) {
return createErrorResponse(headerError);
}
// Check if the value is valid JSON if it starts with { or [
if (args.value && typeof args.value === 'string' &&
(args.value.startsWith('{') || args.value.startsWith('['))) {
try {
JSON.parse(args.value);
}
catch (error) {
return createErrorResponse(`Failed to parse request body: ${error.message}`);
}
}
const response = await apiContext.post(args.url, {
data: parseJsonSafely(args.value),
headers: buildHeaders(args.token, args.headers, true)
});
let responseText;
try {
responseText = await response.text();
}
catch (error) {
responseText = "Unable to get response text";
}
return createSuccessResponse([
`POST request to ${args.url}`,
`Status: ${response.status()} ${response.statusText()}`,
`Response: ${responseText.substring(0, 1000)}${responseText.length > 1000 ? '...' : ''}`
]);
});
}
}
/**
* Tool for making PUT requests
*/
export class PutRequestTool extends ApiToolBase {
/**
* Execute the PUT request tool
*/
async execute(args, context) {
return this.safeExecute(context, async (apiContext) => {
// Validate headers
const headerError = validateHeaders(args.headers);
if (headerError) {
return createErrorResponse(headerError);
}
// Check if the value is valid JSON if it starts with { or [
if (args.value && typeof args.value === 'string' &&
(args.value.startsWith('{') || args.value.startsWith('['))) {
try {
JSON.parse(args.value);
}
catch (error) {
return createErrorResponse(`Failed to parse request body: ${error.message}`);
}
}
const response = await apiContext.put(args.url, {
data: parseJsonSafely(args.value),
headers: buildHeaders(args.token, args.headers, true)
});
let responseText;
try {
responseText = await response.text();
}
catch (error) {
responseText = "Unable to get response text";
}
return createSuccessResponse([
`PUT request to ${args.url}`,
`Status: ${response.status()} ${response.statusText()}`,
`Response: ${responseText.substring(0, 1000)}${responseText.length > 1000 ? '...' : ''}`
]);
});
}
}
/**
* Tool for making PATCH requests
*/
export class PatchRequestTool extends ApiToolBase {
/**
* Execute the PATCH request tool
*/
async execute(args, context) {
return this.safeExecute(context, async (apiContext) => {
// Validate headers
const headerError = validateHeaders(args.headers);
if (headerError) {
return createErrorResponse(headerError);
}
// Check if the value is valid JSON if it starts with { or [
if (args.value && typeof args.value === 'string' &&
(args.value.startsWith('{') || args.value.startsWith('['))) {
try {
JSON.parse(args.value);
}
catch (error) {
return createErrorResponse(`Failed to parse request body: ${error.message}`);
}
}
const response = await apiContext.patch(args.url, {
data: parseJsonSafely(args.value),
headers: buildHeaders(args.token, args.headers, true)
});
let responseText;
try {
responseText = await response.text();
}
catch (error) {
responseText = "Unable to get response text";
}
return createSuccessResponse([
`PATCH request to ${args.url}`,
`Status: ${response.status()} ${response.statusText()}`,
`Response: ${responseText.substring(0, 1000)}${responseText.length > 1000 ? '...' : ''}`
]);
});
}
}
/**
* Tool for making DELETE requests
*/
export class DeleteRequestTool extends ApiToolBase {
/**
* Execute the DELETE request tool
*/
async execute(args, context) {
return this.safeExecute(context, async (apiContext) => {
// Validate headers
const headerError = validateHeaders(args.headers);
if (headerError) {
return createErrorResponse(headerError);
}
const response = await apiContext.delete(args.url, {
headers: buildHeaders(args.token, args.headers)
});
let responseText;
try {
responseText = await response.text();
}
catch (error) {
responseText = "Unable to get response text";
}
return createSuccessResponse([
`DELETE request to ${args.url}`,
`Status: ${response.status()} ${response.statusText()}`,
`Response: ${responseText.substring(0, 1000)}${responseText.length > 1000 ? '...' : ''}`
]);
});
}
}

View File

@@ -0,0 +1,33 @@
import type { Page } from 'playwright';
import { ToolHandler, ToolContext, ToolResponse } from '../common/types.js';
/**
* Base class for all browser-based tools
* Provides common functionality and error handling
*/
export declare abstract class BrowserToolBase implements ToolHandler {
protected server: any;
constructor(server: any);
/**
* Main execution method that all tools must implement
*/
abstract execute(args: any, context: ToolContext): Promise<ToolResponse>;
/**
* Ensures a page is available and returns it
* @param context The tool context containing browser and page
* @returns The page or null if not available
*/
protected ensurePage(context: ToolContext): Page | null;
/**
* Validates that a page is available and returns an error response if not
* @param context The tool context
* @returns Either null if page is available, or an error response
*/
protected validatePageAvailable(context: ToolContext): ToolResponse | null;
/**
* Safely executes a browser operation with proper error handling
* @param context The tool context
* @param operation The async operation to perform
* @returns The tool response
*/
protected safeExecute(context: ToolContext, operation: (page: Page) => Promise<ToolResponse>): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,72 @@
import { createErrorResponse } from '../common/types.js';
/**
* Base class for all browser-based tools
* Provides common functionality and error handling
*/
export class BrowserToolBase {
constructor(server) {
this.server = server;
}
/**
* Ensures a page is available and returns it
* @param context The tool context containing browser and page
* @returns The page or null if not available
*/
ensurePage(context) {
if (!context.page) {
return null;
}
return context.page;
}
/**
* Validates that a page is available and returns an error response if not
* @param context The tool context
* @returns Either null if page is available, or an error response
*/
validatePageAvailable(context) {
if (!this.ensurePage(context)) {
return createErrorResponse("Browser page not initialized!");
}
return null;
}
/**
* Safely executes a browser operation with proper error handling
* @param context The tool context
* @param operation The async operation to perform
* @returns The tool response
*/
async safeExecute(context, operation) {
const pageError = this.validatePageAvailable(context);
if (pageError)
return pageError;
try {
// Verify browser is connected before proceeding
if (context.browser && !context.browser.isConnected()) {
// If browser exists but is disconnected, reset state
const { resetBrowserState } = await import('../../toolHandler.js');
resetBrowserState();
return createErrorResponse("Browser is disconnected. Please retry the operation.");
}
// Check if page is closed
if (context.page.isClosed()) {
return createErrorResponse("Page is closed. Please retry the operation.");
}
return await operation(context.page);
}
catch (error) {
const errorMessage = error.message;
// Check for common browser disconnection errors
if (errorMessage.includes("Target page, context or browser has been closed") ||
errorMessage.includes("Target closed") ||
errorMessage.includes("Browser has been disconnected") ||
errorMessage.includes("Protocol error") ||
errorMessage.includes("Connection closed")) {
// Reset browser state on connection issues
const { resetBrowserState } = await import('../../toolHandler.js');
resetBrowserState();
return createErrorResponse(`Browser connection error: ${errorMessage}. Connection has been reset - please retry the operation.`);
}
return createErrorResponse(`Operation failed: ${errorMessage}`);
}
}
}

View File

@@ -0,0 +1,26 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for retrieving and filtering console logs from the browser
*/
export declare class ConsoleLogsTool extends BrowserToolBase {
private consoleLogs;
/**
* Register a console message
* @param type The type of console message
* @param text The text content of the message
*/
registerConsoleMessage(type: string, text: string): void;
/**
* Execute the console logs tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
/**
* Get all console logs
*/
getConsoleLogs(): string[];
/**
* Clear all console logs
*/
clearConsoleLogs(): void;
}

View File

@@ -0,0 +1,66 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse } from '../common/types.js';
/**
* Tool for retrieving and filtering console logs from the browser
*/
export class ConsoleLogsTool extends BrowserToolBase {
constructor() {
super(...arguments);
this.consoleLogs = [];
}
/**
* Register a console message
* @param type The type of console message
* @param text The text content of the message
*/
registerConsoleMessage(type, text) {
const logEntry = `[${type}] ${text}`;
this.consoleLogs.push(logEntry);
}
/**
* Execute the console logs tool
*/
async execute(args, context) {
// No need to use safeExecute here as we don't need to interact with the page
// We're just filtering and returning logs that are already stored
let logs = [...this.consoleLogs];
// Filter by type if specified
if (args.type && args.type !== 'all') {
logs = logs.filter(log => log.startsWith(`[${args.type}]`));
}
// Filter by search text if specified
if (args.search) {
logs = logs.filter(log => log.includes(args.search));
}
// Limit the number of logs if specified
if (args.limit && args.limit > 0) {
logs = logs.slice(-args.limit);
}
// Clear logs if requested
if (args.clear) {
this.consoleLogs = [];
}
// Format the response
if (logs.length === 0) {
return createSuccessResponse("No console logs matching the criteria");
}
else {
return createSuccessResponse([
`Retrieved ${logs.length} console log(s):`,
...logs
]);
}
}
/**
* Get all console logs
*/
getConsoleLogs() {
return this.consoleLogs;
}
/**
* Clear all console logs
*/
clearConsoleLogs() {
this.consoleLogs = [];
}
}

View File

@@ -0,0 +1,8 @@
export * from './base.js';
export * from './screenshot.js';
export * from './navigation.js';
export * from './console.js';
export * from './interaction.js';
export * from './response.js';
export * from './useragent.js';
export * from './resize.js';

View File

@@ -0,0 +1,10 @@
export * from './base.js';
export * from './screenshot.js';
export * from './navigation.js';
export * from './console.js';
export * from './interaction.js';
export * from './response.js';
export * from './useragent.js';
export * from './resize.js';
// TODO: Add exports for other browser tools as they are implemented
// export * from './interaction.js';

View File

@@ -0,0 +1,104 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for clicking elements on the page
*/
export declare class ClickTool extends BrowserToolBase {
/**
* Execute the click tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for clicking a link and switching to the new tab
*/
export declare class ClickAndSwitchTabTool extends BrowserToolBase {
/**
* Execute the click and switch tab tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for clicking elements inside iframes
*/
export declare class IframeClickTool extends BrowserToolBase {
/**
* Execute the iframe click tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for filling elements inside iframes
*/
export declare class IframeFillTool extends BrowserToolBase {
/**
* Execute the iframe fill tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for filling form fields
*/
export declare class FillTool extends BrowserToolBase {
/**
* Execute the fill tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for selecting options from dropdown menus
*/
export declare class SelectTool extends BrowserToolBase {
/**
* Execute the select tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for hovering over elements
*/
export declare class HoverTool extends BrowserToolBase {
/**
* Execute the hover tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for uploading files
*/
export declare class UploadFileTool extends BrowserToolBase {
/**
* Execute the upload file tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for executing JavaScript in the browser
*/
export declare class EvaluateTool extends BrowserToolBase {
/**
* Execute the evaluate tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for dragging elements on the page
*/
export declare class DragTool extends BrowserToolBase {
/**
* Execute the drag tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for pressing keyboard keys
*/
export declare class PressKeyTool extends BrowserToolBase {
/**
* Execute the key press tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for switching browser tabs
*/

View File

@@ -0,0 +1,235 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
import { setGlobalPage } from '../../toolHandler.js';
/**
* Tool for clicking elements on the page
*/
export class ClickTool extends BrowserToolBase {
/**
* Execute the click tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.click(args.selector);
return createSuccessResponse(`Clicked element: ${args.selector}`);
});
}
}
/**
* Tool for clicking a link and switching to the new tab
*/
export class ClickAndSwitchTabTool extends BrowserToolBase {
/**
* Execute the click and switch tab tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
// Listen for a new tab to open
const [newPage] = await Promise.all([
//context.browser.waitForEvent('page'), // Wait for a new page (tab) to open
page.context().waitForEvent('page'), // Wait for a new page (tab) to open
page.click(args.selector), // Click the link that opens the new tab
]);
// Wait for the new page to load
await newPage.waitForLoadState('domcontentloaded');
// Switch control to the new tab
setGlobalPage(newPage);
//page= newPage; // Update the current page to the new tab
//context.page = newPage;
//context.page.bringToFront(); // Bring the new tab to the front
return createSuccessResponse(`Clicked link and switched to new tab: ${newPage.url()}`);
//return createSuccessResponse(`Clicked link and switched to new tab: ${context.page.url()}`);
});
}
}
/**
* Tool for clicking elements inside iframes
*/
export class IframeClickTool extends BrowserToolBase {
/**
* Execute the iframe click tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const frame = page.frameLocator(args.iframeSelector);
if (!frame) {
return createErrorResponse(`Iframe not found: ${args.iframeSelector}`);
}
await frame.locator(args.selector).click();
return createSuccessResponse(`Clicked element ${args.selector} inside iframe ${args.iframeSelector}`);
});
}
}
/**
* Tool for filling elements inside iframes
*/
export class IframeFillTool extends BrowserToolBase {
/**
* Execute the iframe fill tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const frame = page.frameLocator(args.iframeSelector);
if (!frame) {
return createErrorResponse(`Iframe not found: ${args.iframeSelector}`);
}
await frame.locator(args.selector).fill(args.value);
return createSuccessResponse(`Filled element ${args.selector} inside iframe ${args.iframeSelector} with: ${args.value}`);
});
}
}
/**
* Tool for filling form fields
*/
export class FillTool extends BrowserToolBase {
/**
* Execute the fill tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.waitForSelector(args.selector);
await page.fill(args.selector, args.value);
return createSuccessResponse(`Filled ${args.selector} with: ${args.value}`);
});
}
}
/**
* Tool for selecting options from dropdown menus
*/
export class SelectTool extends BrowserToolBase {
/**
* Execute the select tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.waitForSelector(args.selector);
await page.selectOption(args.selector, args.value);
return createSuccessResponse(`Selected ${args.selector} with: ${args.value}`);
});
}
}
/**
* Tool for hovering over elements
*/
export class HoverTool extends BrowserToolBase {
/**
* Execute the hover tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.waitForSelector(args.selector);
await page.hover(args.selector);
return createSuccessResponse(`Hovered ${args.selector}`);
});
}
}
/**
* Tool for uploading files
*/
export class UploadFileTool extends BrowserToolBase {
/**
* Execute the upload file tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.waitForSelector(args.selector);
await page.setInputFiles(args.selector, args.filePath);
return createSuccessResponse(`Uploaded file '${args.filePath}' to '${args.selector}'`);
});
}
}
/**
* Tool for executing JavaScript in the browser
*/
export class EvaluateTool extends BrowserToolBase {
/**
* Execute the evaluate tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const result = await page.evaluate(args.script);
// Convert result to string for display
let resultStr;
try {
resultStr = JSON.stringify(result, null, 2);
}
catch (error) {
resultStr = String(result);
}
return createSuccessResponse([
`Executed JavaScript:`,
`${args.script}`,
`Result:`,
`${resultStr}`
]);
});
}
}
/**
* Tool for dragging elements on the page
*/
export class DragTool extends BrowserToolBase {
/**
* Execute the drag tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const sourceElement = await page.waitForSelector(args.sourceSelector);
const targetElement = await page.waitForSelector(args.targetSelector);
const sourceBound = await sourceElement.boundingBox();
const targetBound = await targetElement.boundingBox();
if (!sourceBound || !targetBound) {
return createErrorResponse("Could not get element positions for drag operation");
}
await page.mouse.move(sourceBound.x + sourceBound.width / 2, sourceBound.y + sourceBound.height / 2);
await page.mouse.down();
await page.mouse.move(targetBound.x + targetBound.width / 2, targetBound.y + targetBound.height / 2);
await page.mouse.up();
return createSuccessResponse(`Dragged element from ${args.sourceSelector} to ${args.targetSelector}`);
});
}
}
/**
* Tool for pressing keyboard keys
*/
export class PressKeyTool extends BrowserToolBase {
/**
* Execute the key press tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
if (args.selector) {
await page.waitForSelector(args.selector);
await page.focus(args.selector);
}
await page.keyboard.press(args.key);
return createSuccessResponse(`Pressed key: ${args.key}`);
});
}
}
/**
* Tool for switching browser tabs
*/
// export class SwitchTabTool extends BrowserToolBase {
// /**
// * Switch the tab to the specified index
// */
// async execute(args: any, context: ToolContext): Promise<ToolResponse> {
// return this.safeExecute(context, async (page) => {
// const tabs = await browser.page;
// // Validate the tab index
// const tabIndex = Number(args.index);
// if (isNaN(tabIndex)) {
// return createErrorResponse(`Invalid tab index: ${args.index}. It must be a number.`);
// }
// if (tabIndex >= 0 && tabIndex < tabs.length) {
// await tabs[tabIndex].bringToFront();
// return createSuccessResponse(`Switched to tab with index ${tabIndex}`);
// } else {
// return createErrorResponse(
// `Tab index out of range: ${tabIndex}. Available tabs: 0 to ${tabs.length - 1}.`
// );
// }
// });
// }
// }

View File

@@ -0,0 +1,38 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for navigating to URLs
*/
export declare class NavigationTool extends BrowserToolBase {
/**
* Execute the navigation tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for closing the browser
*/
export declare class CloseBrowserTool extends BrowserToolBase {
/**
* Execute the close browser tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for navigating back in browser history
*/
export declare class GoBackTool extends BrowserToolBase {
/**
* Execute the go back tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for navigating forward in browser history
*/
export declare class GoForwardTool extends BrowserToolBase {
/**
* Execute the go forward tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,106 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
import { resetBrowserState } from '../../toolHandler.js';
/**
* Tool for navigating to URLs
*/
export class NavigationTool extends BrowserToolBase {
/**
* Execute the navigation tool
*/
async execute(args, context) {
// Check if browser is available
if (!context.browser || !context.browser.isConnected()) {
// If browser is not connected, we need to reset the state to force recreation
resetBrowserState();
return createErrorResponse("Browser is not connected. The connection has been reset - please retry your navigation.");
}
// Check if page is available and not closed
if (!context.page || context.page.isClosed()) {
return createErrorResponse("Page is not available or has been closed. Please retry your navigation.");
}
return this.safeExecute(context, async (page) => {
try {
await page.goto(args.url, {
timeout: args.timeout || 30000,
waitUntil: args.waitUntil || "load"
});
return createSuccessResponse(`Navigated to ${args.url}`);
}
catch (error) {
const errorMessage = error.message;
// Check for common disconnection errors
if (errorMessage.includes("Target page, context or browser has been closed") ||
errorMessage.includes("Target closed") ||
errorMessage.includes("Browser has been disconnected")) {
// Reset browser state to force recreation on next attempt
resetBrowserState();
return createErrorResponse(`Browser connection issue: ${errorMessage}. Connection has been reset - please retry your navigation.`);
}
// For other errors, return the standard error
throw error;
}
});
}
}
/**
* Tool for closing the browser
*/
export class CloseBrowserTool extends BrowserToolBase {
/**
* Execute the close browser tool
*/
async execute(args, context) {
if (context.browser) {
try {
// Check if browser is still connected
if (context.browser.isConnected()) {
await context.browser.close().catch(error => {
console.error("Error while closing browser:", error);
});
}
else {
console.error("Browser already disconnected, cleaning up state");
}
}
catch (error) {
console.error("Error during browser close operation:", error);
// Continue with resetting state even if close fails
}
finally {
// Always reset the global browser and page references
resetBrowserState();
}
return createSuccessResponse("Browser closed successfully");
}
return createSuccessResponse("No browser instance to close");
}
}
/**
* Tool for navigating back in browser history
*/
export class GoBackTool extends BrowserToolBase {
/**
* Execute the go back tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.goBack();
return createSuccessResponse("Navigated back in browser history");
});
}
}
/**
* Tool for navigating forward in browser history
*/
export class GoForwardTool extends BrowserToolBase {
/**
* Execute the go forward tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
await page.goForward();
return createSuccessResponse("Navigated forward in browser history");
});
}
}

View File

@@ -0,0 +1,11 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for saving page as PDF
*/
export declare class SaveAsPdfTool extends BrowserToolBase {
/**
* Execute the save as PDF tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,29 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse } from '../common/types.js';
import * as path from 'path';
/**
* Tool for saving page as PDF
*/
export class SaveAsPdfTool extends BrowserToolBase {
/**
* Execute the save as PDF tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const filename = args.filename || 'page.pdf';
const options = {
path: path.resolve(args.outputPath || '.', filename),
format: args.format || 'A4',
printBackground: args.printBackground !== false,
margin: args.margin || {
top: '1cm',
right: '1cm',
bottom: '1cm',
left: '1cm'
}
};
await page.pdf(options);
return createSuccessResponse(`Saved page as PDF: ${options.path}`);
});
}
}

View File

@@ -0,0 +1,11 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for resizing the browser viewport with device preset support
*/
export declare class ResizeTool extends BrowserToolBase {
/**
* Execute the resize tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,99 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
import { devices } from 'playwright';
/**
* Tool for resizing the browser viewport with device preset support
*/
export class ResizeTool extends BrowserToolBase {
/**
* Execute the resize tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
let width;
let height;
let deviceName;
let shouldSetUserAgent = false;
let userAgent;
let isMobile = false;
let hasTouch = false;
let deviceScaleFactor = 1;
// Check if using device preset
if (args.device) {
const device = devices[args.device];
if (!device) {
// List some popular devices for suggestions
const popularDevices = [
'iPhone 13', 'iPhone 13 Pro', 'iPhone 14', 'iPhone 15',
'iPad Pro 11', 'iPad Pro 12.9',
'Pixel 5', 'Pixel 7',
'Galaxy S9+', 'Galaxy S24',
'Desktop Chrome', 'Desktop Firefox', 'Desktop Safari'
];
return createErrorResponse(`Device "${args.device}" not found. Popular devices: ${popularDevices.join(', ')}. ` +
`Use playwright.devices to see all ${Object.keys(devices).length} available devices.`);
}
// Extract device properties
width = device.viewport.width;
height = device.viewport.height;
deviceName = args.device;
shouldSetUserAgent = true;
userAgent = device.userAgent;
isMobile = device.isMobile || false;
hasTouch = device.hasTouch || false;
deviceScaleFactor = device.deviceScaleFactor || 1;
// Handle orientation
if (args.orientation === 'landscape' && width < height) {
[width, height] = [height, width];
}
else if (args.orientation === 'portrait' && width > height) {
[width, height] = [height, width];
}
}
else {
// Manual dimensions
width = args.width;
height = args.height;
// Check if dimensions are provided
if (width === undefined || height === undefined) {
return createErrorResponse("Either 'device' parameter or both 'width' and 'height' parameters are required");
}
// Validate dimensions
if (width <= 0 || height <= 0) {
throw new Error("Width and height must be positive integers");
}
if (width > 7680 || height > 4320) {
throw new Error("Width and height must not exceed 7680x4320 (8K resolution)");
}
}
// Apply viewport resize
await page.setViewportSize({ width, height });
// If using device preset, also update user agent and touch settings
if (shouldSetUserAgent && userAgent) {
await page.setExtraHTTPHeaders({
'User-Agent': userAgent
});
}
// Construct success message
let message = deviceName
? `Browser viewport resized to ${deviceName} (${width}x${height})`
: `Browser viewport resized to ${width}x${height}`;
if (args.orientation) {
message += ` in ${args.orientation} orientation`;
}
if (deviceName) {
const features = [];
if (isMobile)
features.push('mobile');
if (hasTouch)
features.push('touch');
if (deviceScaleFactor > 1)
features.push(`${deviceScaleFactor}x scale`);
if (features.length > 0) {
message += ` [${features.join(', ')}]`;
}
}
return createSuccessResponse(message);
});
}
}

View File

@@ -0,0 +1,29 @@
import { BrowserToolBase } from './base.js';
import type { ToolContext, ToolResponse } from '../common/types.js';
interface ExpectResponseArgs {
id: string;
url: string;
}
interface AssertResponseArgs {
id: string;
value?: string;
}
/**
* Tool for setting up response wait operations
*/
export declare class ExpectResponseTool extends BrowserToolBase {
/**
* Execute the expect response tool
*/
execute(args: ExpectResponseArgs, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for asserting and validating responses
*/
export declare class AssertResponseTool extends BrowserToolBase {
/**
* Execute the assert response tool
*/
execute(args: AssertResponseArgs, context: ToolContext): Promise<ToolResponse>;
}
export {};

View File

@@ -0,0 +1,67 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
const responsePromises = new Map();
/**
* Tool for setting up response wait operations
*/
export class ExpectResponseTool extends BrowserToolBase {
/**
* Execute the expect response tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
if (!args.id || !args.url) {
return createErrorResponse("Missing required parameters: id and url must be provided");
}
const responsePromise = page.waitForResponse(args.url);
responsePromises.set(args.id, responsePromise);
return createSuccessResponse(`Started waiting for response with ID ${args.id}`);
});
}
}
/**
* Tool for asserting and validating responses
*/
export class AssertResponseTool extends BrowserToolBase {
/**
* Execute the assert response tool
*/
async execute(args, context) {
return this.safeExecute(context, async () => {
if (!args.id) {
return createErrorResponse("Missing required parameter: id must be provided");
}
const responsePromise = responsePromises.get(args.id);
if (!responsePromise) {
return createErrorResponse(`No response wait operation found with ID: ${args.id}`);
}
try {
const response = await responsePromise;
const body = await response.json();
if (args.value) {
const bodyStr = JSON.stringify(body);
if (!bodyStr.includes(args.value)) {
const messages = [
`Response body does not contain expected value: ${args.value}`,
`Actual body: ${bodyStr}`
];
return createErrorResponse(messages.join('\n'));
}
}
const messages = [
`Response assertion for ID ${args.id} successful`,
`URL: ${response.url()}`,
`Status: ${response.status()}`,
`Body: ${JSON.stringify(body, null, 2)}`
];
return createSuccessResponse(messages.join('\n'));
}
catch (error) {
return createErrorResponse(`Failed to assert response: ${error.message}`);
}
finally {
responsePromises.delete(args.id);
}
});
}
}

View File

@@ -0,0 +1,16 @@
import { BrowserToolBase } from './base.js';
import { ToolContext, ToolResponse } from '../common/types.js';
/**
* Tool for taking screenshots of pages or elements
*/
export declare class ScreenshotTool extends BrowserToolBase {
private screenshots;
/**
* Execute the screenshot tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
/**
* Get all stored screenshots
*/
getScreenshots(): Map<string, string>;
}

View File

@@ -0,0 +1,66 @@
import fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { BrowserToolBase } from './base.js';
import { createSuccessResponse } from '../common/types.js';
const defaultDownloadsPath = path.join(os.homedir(), 'Downloads');
/**
* Tool for taking screenshots of pages or elements
*/
export class ScreenshotTool extends BrowserToolBase {
constructor() {
super(...arguments);
this.screenshots = new Map();
}
/**
* Execute the screenshot tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
const screenshotOptions = {
type: args.type || "png",
fullPage: !!args.fullPage
};
if (args.selector) {
const element = await page.$(args.selector);
if (!element) {
return {
content: [{
type: "text",
text: `Element not found: ${args.selector}`,
}],
isError: true
};
}
screenshotOptions.element = element;
}
// Generate output path
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${args.name || 'screenshot'}-${timestamp}.png`;
const downloadsDir = args.downloadsDir || defaultDownloadsPath;
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
const outputPath = path.join(downloadsDir, filename);
screenshotOptions.path = outputPath;
const screenshot = await page.screenshot(screenshotOptions);
const base64Screenshot = screenshot.toString('base64');
const messages = [`Screenshot saved to: ${path.relative(process.cwd(), outputPath)}`];
// Handle base64 storage
if (args.storeBase64 !== false) {
this.screenshots.set(args.name || 'screenshot', base64Screenshot);
this.server.notification({
method: "notifications/resources/list_changed",
});
messages.push(`Screenshot also stored in memory with name: '${args.name || 'screenshot'}'`);
}
return createSuccessResponse(messages);
});
}
/**
* Get all stored screenshots
*/
getScreenshots() {
return this.screenshots;
}
}

View File

@@ -0,0 +1,15 @@
import { BrowserToolBase } from './base.js';
import type { ToolContext, ToolResponse } from '../common/types.js';
interface CustomUserAgentArgs {
userAgent: string;
}
/**
* Tool for validating custom User Agent settings
*/
export declare class CustomUserAgentTool extends BrowserToolBase {
/**
* Execute the custom user agent tool
*/
execute(args: CustomUserAgentArgs, context: ToolContext): Promise<ToolResponse>;
}
export {};

View File

@@ -0,0 +1,32 @@
import { BrowserToolBase } from './base.js';
import { createSuccessResponse, createErrorResponse } from '../common/types.js';
/**
* Tool for validating custom User Agent settings
*/
export class CustomUserAgentTool extends BrowserToolBase {
/**
* Execute the custom user agent tool
*/
async execute(args, context) {
return this.safeExecute(context, async (page) => {
if (!args.userAgent) {
return createErrorResponse("Missing required parameter: userAgent must be provided");
}
try {
const currentUserAgent = await page.evaluate(() => navigator.userAgent);
if (currentUserAgent !== args.userAgent) {
const messages = [
"Page was already initialized with a different User Agent.",
`Requested: ${args.userAgent}`,
`Current: ${currentUserAgent}`
];
return createErrorResponse(messages.join('\n'));
}
return createSuccessResponse("User Agent validation successful");
}
catch (error) {
return createErrorResponse(`Failed to validate User Agent: ${error.message}`);
}
});
}
}

View File

@@ -0,0 +1,20 @@
import { ToolContext, ToolResponse } from "../common/types.js";
import { BrowserToolBase } from "./base.js";
/**
* Tool for getting the visible text content of the current page
*/
export declare class VisibleTextTool extends BrowserToolBase {
/**
* Execute the visible text page tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
/**
* Tool for getting the visible HTML content of the current page
*/
export declare class VisibleHtmlTool extends BrowserToolBase {
/**
* Execute the visible HTML page tool
*/
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}

View File

@@ -0,0 +1,168 @@
import { resetBrowserState } from "../../toolHandler.js";
import { createErrorResponse, createSuccessResponse } from "../common/types.js";
import { BrowserToolBase } from "./base.js";
/**
* Tool for getting the visible text content of the current page
*/
export class VisibleTextTool extends BrowserToolBase {
/**
* Execute the visible text page tool
*/
async execute(args, context) {
// Check if browser is available
if (!context.browser || !context.browser.isConnected()) {
// If browser is not connected, we need to reset the state to force recreation
resetBrowserState();
return createErrorResponse("Browser is not connected. The connection has been reset - please retry your navigation.");
}
// Check if page is available and not closed
if (!context.page || context.page.isClosed()) {
return createErrorResponse("Page is not available or has been closed. Please retry your navigation.");
}
return this.safeExecute(context, async (page) => {
try {
const visibleText = await page.evaluate(() => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const style = window.getComputedStyle(node.parentElement);
return (style.display !== "none" && style.visibility !== "hidden")
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let text = "";
let node;
while ((node = walker.nextNode())) {
const trimmedText = node.textContent?.trim();
if (trimmedText) {
text += trimmedText + "\n";
}
}
return text.trim();
});
// Truncate logic
const maxLength = typeof args.maxLength === 'number' ? args.maxLength : 20000;
let output = visibleText;
let truncated = false;
if (output.length > maxLength) {
output = output.slice(0, maxLength) + '\n[Output truncated due to size limits]';
truncated = true;
}
return createSuccessResponse(`Visible text content:\n${output}`);
}
catch (error) {
return createErrorResponse(`Failed to get visible text content: ${error.message}`);
}
});
}
}
/**
* Tool for getting the visible HTML content of the current page
*/
export class VisibleHtmlTool extends BrowserToolBase {
/**
* Execute the visible HTML page tool
*/
async execute(args, context) {
// Check if browser is available
if (!context.browser || !context.browser.isConnected()) {
// If browser is not connected, we need to reset the state to force recreation
resetBrowserState();
return createErrorResponse("Browser is not connected. The connection has been reset - please retry your navigation.");
}
// Check if page is available and not closed
if (!context.page || context.page.isClosed()) {
return createErrorResponse("Page is not available or has been closed. Please retry your navigation.");
}
return this.safeExecute(context, async (page) => {
try {
const { selector, removeComments, removeStyles, removeMeta, minify, cleanHtml } = args;
// Default removeScripts to true unless explicitly set to false
const removeScripts = args.removeScripts === false ? false : true;
// Get the HTML content
let htmlContent;
if (selector) {
// If a selector is provided, get only the HTML for that element
const element = await page.$(selector);
if (!element) {
return createErrorResponse(`Element with selector "${selector}" not found`);
}
htmlContent = await page.evaluate((el) => el.outerHTML, element);
}
else {
// Otherwise get the full page HTML
htmlContent = await page.content();
}
// Determine if we need to apply filters
const shouldRemoveScripts = removeScripts || cleanHtml;
const shouldRemoveComments = removeComments || cleanHtml;
const shouldRemoveStyles = removeStyles || cleanHtml;
const shouldRemoveMeta = removeMeta || cleanHtml;
// Apply filters in the browser context
if (shouldRemoveScripts || shouldRemoveComments || shouldRemoveStyles || shouldRemoveMeta || minify) {
htmlContent = await page.evaluate(({ html, removeScripts, removeComments, removeStyles, removeMeta, minify }) => {
// Create a DOM parser to work with the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Remove script tags if requested
if (removeScripts) {
const scripts = doc.querySelectorAll('script');
scripts.forEach(script => script.remove());
}
// Remove style tags if requested
if (removeStyles) {
const styles = doc.querySelectorAll('style');
styles.forEach(style => style.remove());
}
// Remove meta tags if requested
if (removeMeta) {
const metaTags = doc.querySelectorAll('meta');
metaTags.forEach(meta => meta.remove());
}
// Remove HTML comments if requested
if (removeComments) {
const removeComments = (node) => {
const childNodes = node.childNodes;
for (let i = childNodes.length - 1; i >= 0; i--) {
const child = childNodes[i];
if (child.nodeType === 8) { // 8 is for comment nodes
node.removeChild(child);
}
else if (child.nodeType === 1) { // 1 is for element nodes
removeComments(child);
}
}
};
removeComments(doc.documentElement);
}
// Get the processed HTML
let result = doc.documentElement.outerHTML;
// Minify if requested
if (minify) {
// Simple minification: remove extra whitespace
result = result.replace(/>\s+</g, '><').trim();
}
return result;
}, {
html: htmlContent,
removeScripts: shouldRemoveScripts,
removeComments: shouldRemoveComments,
removeStyles: shouldRemoveStyles,
removeMeta: shouldRemoveMeta,
minify
});
}
// Truncate logic
const maxLength = typeof args.maxLength === 'number' ? args.maxLength : 20000;
let output = htmlContent;
if (output.length > maxLength) {
output = output.slice(0, maxLength) + '\n<!-- Output truncated due to size limits -->';
}
return createSuccessResponse(`HTML content:\n${output}`);
}
catch (error) {
return createErrorResponse(`Failed to get visible HTML content: ${error.message}`);
}
});
}
}

View File

@@ -0,0 +1,21 @@
import { CodegenOptions, CodegenResult, CodegenSession } from './types.js';
export declare class PlaywrightGenerator {
private static readonly DEFAULT_OPTIONS;
private options;
constructor(options?: CodegenOptions);
private validateOptions;
generateTest(session: CodegenSession): Promise<CodegenResult>;
private createTestCase;
private convertActionToStep;
private generateNavigateStep;
private generateFillStep;
private generateClickStep;
private generateScreenshotStep;
private generateExpectResponseStep;
private generateAssertResponseStep;
private generateHoverStep;
private generateSelectStep;
private generateCustomUserAgentStep;
private generateTestCode;
private getOutputFilePath;
}

View File

@@ -0,0 +1,158 @@
import * as path from 'path';
export class PlaywrightGenerator {
constructor(options = {}) {
this.validateOptions(options);
this.options = { ...PlaywrightGenerator.DEFAULT_OPTIONS, ...options };
}
validateOptions(options) {
if (options.outputPath && typeof options.outputPath !== 'string') {
throw new Error('outputPath must be a string');
}
if (options.testNamePrefix && typeof options.testNamePrefix !== 'string') {
throw new Error('testNamePrefix must be a string');
}
if (options.includeComments !== undefined && typeof options.includeComments !== 'boolean') {
throw new Error('includeComments must be a boolean');
}
}
async generateTest(session) {
if (!session || !Array.isArray(session.actions)) {
throw new Error('Invalid session data');
}
const testCase = this.createTestCase(session);
const testCode = this.generateTestCode(testCase);
const filePath = this.getOutputFilePath(session);
return {
testCode,
filePath,
sessionId: session.id,
};
}
createTestCase(session) {
const testCase = {
name: `${this.options.testNamePrefix}_${new Date(session.startTime).toISOString().split('T')[0]}`,
steps: [],
imports: new Set(['test', 'expect']),
};
for (const action of session.actions) {
const step = this.convertActionToStep(action);
if (step) {
testCase.steps.push(step);
}
}
return testCase;
}
convertActionToStep(action) {
const { toolName, parameters } = action;
switch (toolName) {
case 'playwright_navigate':
return this.generateNavigateStep(parameters);
case 'playwright_fill':
return this.generateFillStep(parameters);
case 'playwright_click':
return this.generateClickStep(parameters);
case 'playwright_screenshot':
return this.generateScreenshotStep(parameters);
case 'playwright_expect_response':
return this.generateExpectResponseStep(parameters);
case 'playwright_assert_response':
return this.generateAssertResponseStep(parameters);
case 'playwright_hover':
return this.generateHoverStep(parameters);
case 'playwright_select':
return this.generateSelectStep(parameters);
case 'playwright_custom_user_agent':
return this.generateCustomUserAgentStep(parameters);
default:
console.warn(`Unsupported tool: ${toolName}`);
return null;
}
}
generateNavigateStep(parameters) {
const { url, waitUntil } = parameters;
const options = waitUntil ? `, { waitUntil: '${waitUntil}' }` : '';
return `
// Navigate to URL
await page.goto('${url}'${options});`;
}
generateFillStep(parameters) {
const { selector, value } = parameters;
return `
// Fill input field
await page.fill('${selector}', '${value}');`;
}
generateClickStep(parameters) {
const { selector } = parameters;
return `
// Click element
await page.click('${selector}');`;
}
generateScreenshotStep(parameters) {
const { name, fullPage = false, path } = parameters;
const options = [];
if (fullPage)
options.push('fullPage: true');
if (path)
options.push(`path: '${path}'`);
const optionsStr = options.length > 0 ? `, { ${options.join(', ')} }` : '';
return `
// Take screenshot
await page.screenshot({ path: '${name}.png'${optionsStr} });`;
}
generateExpectResponseStep(parameters) {
const { url, id } = parameters;
return `
// Wait for response
const ${id}Response = page.waitForResponse('${url}');`;
}
generateAssertResponseStep(parameters) {
const { id, value } = parameters;
const assertion = value
? `\n const responseText = await ${id}Response.text();\n expect(responseText).toContain('${value}');`
: `\n expect(${id}Response.ok()).toBeTruthy();`;
return `
// Assert response${assertion}`;
}
generateHoverStep(parameters) {
const { selector } = parameters;
return `
// Hover over element
await page.hover('${selector}');`;
}
generateSelectStep(parameters) {
const { selector, value } = parameters;
return `
// Select option
await page.selectOption('${selector}', '${value}');`;
}
generateCustomUserAgentStep(parameters) {
const { userAgent } = parameters;
return `
// Set custom user agent
await context.setUserAgent('${userAgent}');`;
}
generateTestCode(testCase) {
const imports = Array.from(testCase.imports)
.map(imp => `import { ${imp} } from '@playwright/test';`)
.join('\n');
return `
${imports}
test('${testCase.name}', async ({ page, context }) => {
${testCase.steps.join('\n')}
});`;
}
getOutputFilePath(session) {
if (!session.id) {
throw new Error('Session ID is required');
}
const sanitizedPrefix = this.options.testNamePrefix.toLowerCase().replace(/[^a-z0-9_]/g, '_');
const fileName = `${sanitizedPrefix}_${session.id}.spec.ts`;
return path.resolve(this.options.outputPath, fileName);
}
}
PlaywrightGenerator.DEFAULT_OPTIONS = {
outputPath: 'tests',
testNamePrefix: 'MCP',
includeComments: true,
};

View File

@@ -0,0 +1,11 @@
import { Tool } from '../../types.js';
import type { Browser, Page } from 'playwright';
declare global {
var browser: Browser | undefined;
var page: Page | undefined;
}
export declare const startCodegenSession: Tool;
export declare const endCodegenSession: Tool;
export declare const getCodegenSession: Tool;
export declare const clearCodegenSession: Tool;
export declare const codegenTools: Tool[];

View File

@@ -0,0 +1,187 @@
import { ActionRecorder } from './recorder.js';
import { PlaywrightGenerator } from './generator.js';
import * as fs from 'fs/promises';
import * as path from 'path';
// Helper function to get workspace root path
const getWorkspaceRoot = () => {
return process.cwd();
};
const DEFAULT_OPTIONS = {
outputPath: path.join(getWorkspaceRoot(), 'e2e'),
testNamePrefix: 'Test',
includeComments: true
};
export const startCodegenSession = {
name: 'start_codegen_session',
description: 'Start a new code generation session to record MCP tool actions',
parameters: {
type: 'object',
properties: {
options: {
type: 'object',
description: 'Code generation options',
properties: {
outputPath: { type: 'string' },
testNamePrefix: { type: 'string' },
includeComments: { type: 'boolean' }
}
}
}
},
handler: async ({ options = {} }) => {
try {
// Merge provided options with defaults
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
// Ensure output path is absolute and normalized
const workspaceRoot = getWorkspaceRoot();
const outputPath = path.isAbsolute(mergedOptions.outputPath)
? mergedOptions.outputPath
: path.join(workspaceRoot, mergedOptions.outputPath);
mergedOptions.outputPath = outputPath;
// Ensure output directory exists
try {
await fs.mkdir(outputPath, { recursive: true });
}
catch (mkdirError) {
throw new Error(`Failed to create output directory: ${mkdirError.message}`);
}
const sessionId = ActionRecorder.getInstance().startSession();
// Store options with the session
const recorder = ActionRecorder.getInstance();
const session = recorder.getSession(sessionId);
if (session) {
session.options = mergedOptions;
}
return {
sessionId,
options: mergedOptions,
message: `Started codegen session. Tests will be generated in: ${outputPath}`
};
}
catch (error) {
throw new Error(`Failed to start codegen session: ${error.message}`);
}
}
};
export const endCodegenSession = {
name: 'end_codegen_session',
description: 'End the current code generation session and generate Playwright test',
parameters: {
type: 'object',
properties: {
sessionId: {
type: 'string',
description: 'ID of the session to end'
}
},
required: ['sessionId']
},
handler: async ({ sessionId }) => {
try {
const recorder = ActionRecorder.getInstance();
const session = recorder.endSession(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (!session.options) {
throw new Error(`Session ${sessionId} has no options configured`);
}
const generator = new PlaywrightGenerator(session.options);
const result = await generator.generateTest(session);
// Double check output directory exists
const outputDir = path.dirname(result.filePath);
await fs.mkdir(outputDir, { recursive: true });
// Write test file
try {
await fs.writeFile(result.filePath, result.testCode, 'utf-8');
}
catch (writeError) {
throw new Error(`Failed to write test file: ${writeError.message}`);
}
// Close Playwright browser and cleanup
try {
if (global.browser?.isConnected()) {
await global.browser.close();
}
}
catch (browserError) {
console.warn('Failed to close browser:', browserError.message);
}
finally {
global.browser = undefined;
global.page = undefined;
}
const absolutePath = path.resolve(result.filePath);
return {
filePath: absolutePath,
outputDirectory: outputDir,
testCode: result.testCode,
message: `Generated test file at: ${absolutePath}\nOutput directory: ${outputDir}`
};
}
catch (error) {
// Ensure browser cleanup even on error
try {
if (global.browser?.isConnected()) {
await global.browser.close();
}
}
catch {
// Ignore cleanup errors
}
finally {
global.browser = undefined;
global.page = undefined;
}
throw new Error(`Failed to end codegen session: ${error.message}`);
}
}
};
export const getCodegenSession = {
name: 'get_codegen_session',
description: 'Get information about a code generation session',
parameters: {
type: 'object',
properties: {
sessionId: {
type: 'string',
description: 'ID of the session to retrieve'
}
},
required: ['sessionId']
},
handler: async ({ sessionId }) => {
const session = ActionRecorder.getInstance().getSession(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
return session;
}
};
export const clearCodegenSession = {
name: 'clear_codegen_session',
description: 'Clear a code generation session',
parameters: {
type: 'object',
properties: {
sessionId: {
type: 'string',
description: 'ID of the session to clear'
}
},
required: ['sessionId']
},
handler: async ({ sessionId }) => {
const success = ActionRecorder.getInstance().clearSession(sessionId);
if (!success) {
throw new Error(`Session ${sessionId} not found`);
}
return { success };
}
};
export const codegenTools = [
startCodegenSession,
endCodegenSession,
getCodegenSession,
clearCodegenSession
];

View File

@@ -0,0 +1,14 @@
import { CodegenSession } from './types';
export declare class ActionRecorder {
private static instance;
private sessions;
private activeSession;
private constructor();
static getInstance(): ActionRecorder;
startSession(): string;
endSession(sessionId: string): CodegenSession | null;
recordAction(toolName: string, parameters: Record<string, unknown>, result?: unknown): void;
getSession(sessionId: string): CodegenSession | null;
getActiveSession(): CodegenSession | null;
clearSession(sessionId: string): boolean;
}

View File

@@ -0,0 +1,62 @@
import { v4 as uuidv4 } from 'uuid';
export class ActionRecorder {
constructor() {
this.sessions = new Map();
this.activeSession = null;
}
static getInstance() {
if (!ActionRecorder.instance) {
ActionRecorder.instance = new ActionRecorder();
}
return ActionRecorder.instance;
}
startSession() {
const sessionId = uuidv4();
this.sessions.set(sessionId, {
id: sessionId,
actions: [],
startTime: Date.now(),
});
this.activeSession = sessionId;
return sessionId;
}
endSession(sessionId) {
const session = this.sessions.get(sessionId);
if (session) {
session.endTime = Date.now();
if (this.activeSession === sessionId) {
this.activeSession = null;
}
return session;
}
return null;
}
recordAction(toolName, parameters, result) {
if (!this.activeSession) {
return;
}
const session = this.sessions.get(this.activeSession);
if (!session) {
return;
}
const action = {
toolName,
parameters,
timestamp: Date.now(),
result,
};
session.actions.push(action);
}
getSession(sessionId) {
return this.sessions.get(sessionId) || null;
}
getActiveSession() {
return this.activeSession ? this.sessions.get(this.activeSession) : null;
}
clearSession(sessionId) {
if (this.activeSession === sessionId) {
this.activeSession = null;
}
return this.sessions.delete(sessionId);
}
}

View File

@@ -0,0 +1,28 @@
export interface CodegenAction {
toolName: string;
parameters: Record<string, unknown>;
timestamp: number;
result?: unknown;
}
export interface CodegenSession {
id: string;
actions: CodegenAction[];
startTime: number;
endTime?: number;
options?: CodegenOptions;
}
export interface PlaywrightTestCase {
name: string;
steps: string[];
imports: Set<string>;
}
export interface CodegenOptions {
outputPath?: string;
testNamePrefix?: string;
includeComments?: boolean;
}
export interface CodegenResult {
testCode: string;
filePath: string;
sessionId: string;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,17 @@
import type { CallToolResult, TextContent, ImageContent } from '@modelcontextprotocol/sdk/types.js';
import type { Page, Browser, APIRequestContext } from 'playwright';
export interface ToolContext {
page?: Page;
browser?: Browser;
apiContext?: APIRequestContext;
server?: any;
}
export interface ToolResponse extends CallToolResult {
content: (TextContent | ImageContent)[];
isError: boolean;
}
export interface ToolHandler {
execute(args: any, context: ToolContext): Promise<ToolResponse>;
}
export declare function createErrorResponse(message: string): ToolResponse;
export declare function createSuccessResponse(message: string | string[]): ToolResponse;

View File

@@ -0,0 +1,20 @@
// Helper functions for creating responses
export function createErrorResponse(message) {
return {
content: [{
type: "text",
text: message
}],
isError: true
};
}
export function createSuccessResponse(message) {
const messages = Array.isArray(message) ? message : [message];
return {
content: messages.map(msg => ({
type: "text",
text: msg
})),
isError: false
};
}

View File

@@ -0,0 +1,5 @@
export * from './common/types.js';
export * from './browser/index.js';
export * from './api/index.js';
export declare const BROWSER_TOOLS: string[];
export declare const API_TOOLS: string[];

View File

@@ -0,0 +1,26 @@
export * from './common/types.js';
export * from './browser/index.js';
export * from './api/index.js';
// Tool type constants
export const BROWSER_TOOLS = [
"playwright_navigate",
"playwright_screenshot",
"playwright_click",
"playwright_iframe_click",
"playwright_iframe_fill",
"playwright_fill",
"playwright_select",
"playwright_hover",
"playwright_evaluate",
"playwright_console_logs",
"playwright_close",
"playwright_get_visible_text",
"playwright_get_visible_html"
];
export const API_TOOLS = [
"playwright_get",
"playwright_post",
"playwright_put",
"playwright_patch",
"playwright_delete"
];