feat: add playwright-pro plugin — production-grade Playwright testing toolkit (#254)

Complete Claude Code plugin with:
- 9 skills (/pw:init, generate, review, fix, migrate, coverage, testrail, browserstack, report)
- 3 specialized agents (test-architect, test-debugger, migration-planner)
- 55 test case templates across 11 categories (auth, CRUD, checkout, search, forms, dashboard, settings, onboarding, notifications, API, accessibility)
- TestRail MCP server (TypeScript) — 8 tools for bidirectional sync
- BrowserStack MCP server (TypeScript) — 7 tools for cross-browser testing
- Smart hooks (auto-validate tests, auto-detect Playwright projects)
- 6 curated reference docs (golden rules, locators, assertions, fixtures, pitfalls, flaky tests)
- Leverages Claude Code built-ins (/batch, /debug, Explore subagent)
- Zero-config for core features; TestRail/BrowserStack via env vars
- Both TypeScript and JavaScript support throughout

Co-authored-by: Leo <leo@openclaw.ai>
This commit is contained in:
Alireza Rezvani
2026-03-05 13:50:05 +01:00
committed by GitHub
parent b9a60ed506
commit d33d03da50
98 changed files with 11375 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
import type {
BrowserStackConfig,
BrowserStackPlan,
BrowserStackBrowser,
BrowserStackBuild,
BrowserStackSession,
BrowserStackSessionUpdate,
} from './types.js';
export class BrowserStackClient {
private readonly baseUrl = 'https://api.browserstack.com';
private readonly headers: Record<string, string>;
constructor(config: BrowserStackConfig) {
const auth = Buffer.from(`${config.username}:${config.accessKey}`).toString('base64');
this.headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
};
}
private async request<T>(
method: string,
endpoint: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const options: RequestInit = {
method,
headers: this.headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`BrowserStack API error ${response.status}: ${errorText}`,
);
}
return response.json() as Promise<T>;
}
async getPlan(): Promise<BrowserStackPlan> {
return this.request<BrowserStackPlan>('GET', '/automate/plan.json');
}
async getBrowsers(): Promise<BrowserStackBrowser[]> {
return this.request<BrowserStackBrowser[]>('GET', '/automate/browsers.json');
}
async getBuilds(limit?: number, status?: string): Promise<BrowserStackBuild[]> {
let endpoint = '/automate/builds.json';
const params: string[] = [];
if (limit) params.push(`limit=${limit}`);
if (status) params.push(`status=${status}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return this.request<BrowserStackBuild[]>('GET', endpoint);
}
async getSessions(buildId: string, limit?: number): Promise<BrowserStackSession[]> {
let endpoint = `/automate/builds/${buildId}/sessions.json`;
if (limit) endpoint += `?limit=${limit}`;
return this.request<BrowserStackSession[]>('GET', endpoint);
}
async getSession(sessionId: string): Promise<BrowserStackSession> {
return this.request<BrowserStackSession>(
'GET',
`/automate/sessions/${sessionId}.json`,
);
}
async updateSession(
sessionId: string,
update: BrowserStackSessionUpdate,
): Promise<BrowserStackSession> {
return this.request<BrowserStackSession>(
'PUT',
`/automate/sessions/${sessionId}.json`,
update,
);
}
async getSessionLogs(sessionId: string): Promise<string> {
const url = `${this.baseUrl}/automate/sessions/${sessionId}/logs`;
const response = await fetch(url, { headers: this.headers });
if (!response.ok) {
throw new Error(`BrowserStack logs error ${response.status}`);
}
return response.text();
}
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env npx tsx
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { BrowserStackClient } from './client.js';
import type { BrowserStackSessionUpdate } from './types.js';
const config = {
username: process.env.BROWSERSTACK_USERNAME ?? '',
accessKey: process.env.BROWSERSTACK_ACCESS_KEY ?? '',
};
if (!config.username || !config.accessKey) {
console.error(
'Missing BrowserStack configuration. Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.',
);
process.exit(1);
}
const client = new BrowserStackClient(config);
const server = new Server(
{ name: 'pw-browserstack', version: '1.0.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'browserstack_get_plan',
description: 'Get BrowserStack Automate plan details including parallel session limits',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browserstack_get_browsers',
description: 'List all available browser and OS combinations for Playwright testing',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browserstack_get_builds',
description: 'List recent test builds with status',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max builds to return (default 10)' },
status: {
type: 'string',
enum: ['running', 'done', 'failed', 'timeout'],
description: 'Filter by status',
},
},
},
},
{
name: 'browserstack_get_sessions',
description: 'List test sessions within a build',
inputSchema: {
type: 'object',
properties: {
build_id: { type: 'string', description: 'Build hashed ID' },
limit: { type: 'number', description: 'Max sessions to return' },
},
required: ['build_id'],
},
},
{
name: 'browserstack_get_session',
description: 'Get detailed session info including video URL, logs, and screenshots',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
},
required: ['session_id'],
},
},
{
name: 'browserstack_update_session',
description: 'Update session status (mark as passed/failed) and name',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
status: {
type: 'string',
enum: ['passed', 'failed'],
description: 'Test result status',
},
name: { type: 'string', description: 'Updated session name' },
reason: { type: 'string', description: 'Reason for failure' },
},
required: ['session_id'],
},
},
{
name: 'browserstack_get_logs',
description: 'Get text logs for a specific test session',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
},
required: ['session_id'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'browserstack_get_plan': {
const plan = await client.getPlan();
return { content: [{ type: 'text', text: JSON.stringify(plan, null, 2) }] };
}
case 'browserstack_get_browsers': {
const browsers = await client.getBrowsers();
const playwrightBrowsers = browsers.filter(
(b) =>
['chrome', 'firefox', 'playwright-chromium', 'playwright-firefox', 'playwright-webkit'].includes(
b.browser?.toLowerCase() ?? '',
) || b.browser?.toLowerCase().includes('playwright'),
);
const summary = playwrightBrowsers.length > 0 ? playwrightBrowsers : browsers.slice(0, 50);
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
}
case 'browserstack_get_builds': {
const builds = await client.getBuilds(
(args?.limit as number) ?? 10,
args?.status as string | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(builds, null, 2) }] };
}
case 'browserstack_get_sessions': {
const sessions = await client.getSessions(
args!.build_id as string,
args?.limit as number | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(sessions, null, 2) }] };
}
case 'browserstack_get_session': {
const session = await client.getSession(args!.session_id as string);
return { content: [{ type: 'text', text: JSON.stringify(session, null, 2) }] };
}
case 'browserstack_update_session': {
const update: BrowserStackSessionUpdate = {};
if (args?.status) update.status = args.status as 'passed' | 'failed';
if (args?.name) update.name = args.name as string;
if (args?.reason) update.reason = args.reason as string;
const updated = await client.updateSession(args!.session_id as string, update);
return { content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }] };
}
case 'browserstack_get_logs': {
const logs = await client.getSessionLogs(args!.session_id as string);
return { content: [{ type: 'text', text: logs }] };
}
default:
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);

View File

@@ -0,0 +1,61 @@
export interface BrowserStackConfig {
username: string;
accessKey: string;
}
export interface BrowserStackPlan {
automate_plan: string;
parallel_sessions_running: number;
team_parallel_sessions_max_allowed: number;
parallel_sessions_max_allowed: number;
queued_sessions: number;
queued_sessions_max_allowed: number;
}
export interface BrowserStackBrowser {
os: string;
os_version: string;
browser: string;
browser_version: string;
device: string | null;
real_mobile: boolean | null;
}
export interface BrowserStackBuild {
automation_build: {
name: string;
hashed_id: string;
duration: number;
status: string;
build_tag: string | null;
};
}
export interface BrowserStackSession {
automation_session: {
name: string;
duration: number;
os: string;
os_version: string;
browser_version: string;
browser: string;
device: string | null;
status: string;
hashed_id: string;
reason: string;
build_name: string;
project_name: string;
logs: string;
browser_url: string;
public_url: string;
video_url: string;
browser_console_logs_url: string;
har_logs_url: string;
};
}
export interface BrowserStackSessionUpdate {
name?: string;
status?: 'passed' | 'failed';
reason?: string;
}