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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user