style: fix linting errors and update README with new tools

- Auto-fix trailing spaces, curly braces, and indentation issues
- Clean up boolean comparisons and code formatting
- README automatically updated with new code injection tools:
  - browser_enable_debug_toolbar: Enable debug toolbar for client identification
  - browser_inject_custom_code: Inject custom JavaScript/CSS code
  - browser_list_injections: List all active code injections
  - browser_disable_debug_toolbar: Disable debug toolbar
  - browser_clear_injections: Remove custom code injections

All linting checks now pass successfully.
This commit is contained in:
Ryan Malloy 2025-09-10 01:38:24 -06:00
parent b7ec4faf60
commit a41a73af2a
27 changed files with 1603 additions and 134 deletions

View file

@ -360,7 +360,7 @@ export class Context {
setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) {
// Clear any existing video recording state first
this.clearVideoRecordingState();
this._videoRecordingConfig = config;
this._videoBaseFilename = baseFilename;
@ -370,7 +370,7 @@ export class Context {
// The next call to _ensureBrowserContext will create a new context with video recording
});
}
testDebug(`Video recording configured: ${JSON.stringify(config)}, filename: ${baseFilename}`);
}
@ -417,7 +417,7 @@ export class Context {
colorScheme?: 'light' | 'dark' | 'no-preference';
permissions?: string[];
offline?: boolean;
// Browser UI Customization
chromiumSandbox?: boolean;
slowMo?: number;
@ -495,13 +495,13 @@ export class Context {
// Merge with existing args, avoiding duplicates
const existingArgs = currentConfig.browser.launchOptions.args || [];
const newArgs = [...existingArgs];
for (const arg of changes.args) {
if (!existingArgs.includes(arg)) {
if (!existingArgs.includes(arg))
newArgs.push(arg);
}
}
currentConfig.browser.launchOptions.args = newArgs;
}
@ -580,10 +580,10 @@ export class Context {
// Keep recording config available for inspection until explicitly cleared
// Don't clear it immediately to help with debugging
testDebug(`stopVideoRecording complete: ${videoPaths.length} videos saved, config preserved for debugging`);
// Clear the page tracking but keep config for status queries
this._activePagesWithVideos.clear();
return videoPaths;
}
@ -612,7 +612,7 @@ export class Context {
}
testDebug(`pauseVideoRecording: attempting to pause ${this._activePagesWithVideos.size} active recordings`);
// Store current video objects and close pages to pause recording
let pausedCount = 0;
for (const page of this._activePagesWithVideos) {
@ -633,10 +633,10 @@ export class Context {
this._videoRecordingPaused = true;
testDebug(`Video recording paused: ${pausedCount} recordings stored`);
return {
paused: pausedCount,
message: `Video recording paused. ${pausedCount} active recordings stored.`
return {
paused: pausedCount,
message: `Video recording paused. ${pausedCount} active recordings stored.`
};
}
@ -652,16 +652,16 @@ export class Context {
}
testDebug(`resumeVideoRecording: attempting to resume ${this._pausedPageVideos.size} paused recordings`);
// Resume recording by ensuring fresh browser context
// The paused videos are automatically finalized and new ones will start
let resumedCount = 0;
// Force context recreation to start fresh recording
if (this._browserContextPromise) {
if (this._browserContextPromise)
await this.closeBrowserContext();
}
// Clear the paused videos map as we'll get new video objects
const pausedCount = this._pausedPageVideos.size;
this._pausedPageVideos.clear();
@ -669,10 +669,10 @@ export class Context {
this._videoRecordingPaused = false;
testDebug(`Video recording resumed: ${resumedCount} recordings will restart on next page creation`);
return {
resumed: resumedCount,
message: `Video recording resumed. ${resumedCount} recordings will restart when pages are created.`
return {
resumed: resumedCount,
message: `Video recording resumed. ${resumedCount} recordings will restart when pages are created.`
};
}
@ -691,7 +691,8 @@ export class Context {
}
async beginVideoAction(actionName: string): Promise<void> {
if (!this._videoRecordingConfig || !this._autoRecordingEnabled) return;
if (!this._videoRecordingConfig || !this._autoRecordingEnabled)
return;
testDebug(`beginVideoAction: ${actionName}, mode: ${this._videoRecordingMode}`);
@ -699,27 +700,28 @@ export class Context {
case 'continuous':
// Always recording, no action needed
break;
case 'smart':
case 'action-only':
// Resume recording if paused
if (this._videoRecordingPaused) {
if (this._videoRecordingPaused)
await this.resumeVideoRecording();
}
break;
case 'segment':
// Create new segment for this action
if (this._videoRecordingPaused) {
if (this._videoRecordingPaused)
await this.resumeVideoRecording();
}
// Note: Actual segment creation happens in stopVideoRecording
break;
}
}
async endVideoAction(actionName: string, shouldPause: boolean = true): Promise<void> {
if (!this._videoRecordingConfig || !this._autoRecordingEnabled) return;
if (!this._videoRecordingConfig || !this._autoRecordingEnabled)
return;
testDebug(`endVideoAction: ${actionName}, shouldPause: ${shouldPause}, mode: ${this._videoRecordingMode}`);
@ -727,15 +729,15 @@ export class Context {
case 'continuous':
// Never auto-pause in continuous mode
break;
case 'smart':
case 'action-only':
// Auto-pause after action unless explicitly told not to
if (shouldPause && !this._videoRecordingPaused) {
if (shouldPause && !this._videoRecordingPaused)
await this.pauseVideoRecording();
}
break;
case 'segment':
// Always end segment after action
await this.finalizeCurrentVideoSegment();
@ -744,20 +746,21 @@ export class Context {
}
async finalizeCurrentVideoSegment(): Promise<string[]> {
if (!this._videoRecordingConfig) return [];
if (!this._videoRecordingConfig)
return [];
testDebug(`Finalizing video segment ${this._currentVideoSegment}`);
// Get current video paths before creating new segment
const segmentPaths = await this.stopVideoRecording();
// Immediately restart recording for next segment
this._currentVideoSegment++;
const newFilename = `${this._videoBaseFilename}-segment-${this._currentVideoSegment}`;
// Restart recording with new segment filename
this.setVideoRecording(this._videoRecordingConfig, newFilename);
return segmentPaths;
}
@ -1020,60 +1023,60 @@ export class Context {
* Auto-inject debug toolbar and custom code into a new page
*/
private async _injectCodeIntoPage(page: playwright.Page): Promise<void> {
if (!this.injectionConfig || !this.injectionConfig.enabled) {
if (!this.injectionConfig || !this.injectionConfig.enabled)
return;
}
try {
// Import the injection functions (dynamic import to avoid circular deps)
const { generateDebugToolbarScript, wrapInjectedCode, generateInjectionScript } = await import('./tools/codeInjection.js');
// Inject debug toolbar if enabled
if (this.injectionConfig.debugToolbar.enabled) {
const toolbarScript = generateDebugToolbarScript(
this.injectionConfig.debugToolbar,
this.sessionId,
this.clientVersion,
this._sessionStartTime
this.injectionConfig.debugToolbar,
this.sessionId,
this.clientVersion,
this._sessionStartTime
);
// Add to page init script for future navigations
await page.addInitScript(toolbarScript);
// Execute immediately if page is already loaded
if (page.url() && page.url() !== 'about:blank') {
await page.evaluate(toolbarScript).catch(error => {
testDebug('Error executing debug toolbar script on existing page:', error);
});
}
testDebug(`Debug toolbar auto-injected into page: ${page.url()}`);
}
// Inject custom code
for (const injection of this.injectionConfig.customInjections) {
if (!injection.enabled || !injection.autoInject) {
if (!injection.enabled || !injection.autoInject)
continue;
}
try {
const wrappedCode = wrapInjectedCode(
injection,
this.sessionId,
this.injectionConfig.debugToolbar.projectName
injection,
this.sessionId,
this.injectionConfig.debugToolbar.projectName
);
const injectionScript = generateInjectionScript(wrappedCode);
// Add to page init script
await page.addInitScript(injectionScript);
// Execute immediately if page is already loaded
if (page.url() && page.url() !== 'about:blank') {
await page.evaluate(injectionScript).catch(error => {
testDebug(`Error executing custom injection "${injection.name}" on existing page:`, error);
});
}
testDebug(`Custom injection "${injection.name}" auto-injected into page: ${page.url()}`);
} catch (error) {
testDebug(`Error injecting custom code "${injection.name}":`, error);

View file

@ -1,6 +1,21 @@
/**
* 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.
*/
/**
* Code Injection Tools for MCP Client Identification and Custom Scripts
*
*
* Provides tools for injecting debug toolbars and custom code into browser pages.
* Designed for multi-client MCP environments where identifying which client
* controls which browser window is essential.
@ -47,7 +62,7 @@ export function generateDebugToolbarScript(config: DebugToolbarConfig, sessionId
const projectName = config.projectName || 'MCP Client';
const clientInfo = clientVersion ? `${clientVersion.name} v${clientVersion.version}` : 'Unknown Client';
const startTime = sessionStartTime || Date.now();
return `
/* BEGIN PLAYWRIGHT-MCP-DEBUG-TOOLBAR */
/* This debug toolbar was injected by Playwright MCP server */
@ -217,7 +232,7 @@ export function wrapInjectedCode(injection: CustomInjection, sessionId: string,
<!-- Session: ${sessionId}${projectInfo} -->
<!-- This code was injected by Playwright MCP and should be ignored by LLMs -->`;
const footer = `<!-- END PLAYWRIGHT-MCP-INJECTION: ${injection.name} -->`;
if (injection.type === 'javascript') {
return `${header}
<script>
@ -233,7 +248,7 @@ ${injection.code}
</style>
${footer}`;
}
return `${header}
${injection.code}
${footer}`;
@ -315,7 +330,7 @@ const enableDebugToolbar = defineTool({
},
handle: async (context: Context, params: z.output<typeof enableDebugToolbarSchema>, response: Response) => {
testDebug('Enabling debug toolbar with params:', params);
const config: DebugToolbarConfig = {
enabled: true,
projectName: params.projectName || 'MCP Client',
@ -325,7 +340,7 @@ const enableDebugToolbar = defineTool({
showDetails: params.showDetails !== false,
opacity: params.opacity || 0.9
};
// Store config in context
if (!context.injectionConfig) {
context.injectionConfig = {
@ -337,10 +352,10 @@ const enableDebugToolbar = defineTool({
context.injectionConfig.debugToolbar = config;
context.injectionConfig.enabled = true;
}
// Generate toolbar script
const toolbarScript = generateDebugToolbarScript(config, context.sessionId, context.clientVersion, (context as any)._sessionStartTime);
// Inject into current page if available
const currentTab = context.currentTab();
if (currentTab) {
@ -352,7 +367,7 @@ const enableDebugToolbar = defineTool({
testDebug('Error injecting toolbar into current page:', error);
}
}
const resultMessage = `Debug toolbar enabled for project "${config.projectName}"`;
response.addResult(resultMessage);
response.addResult(`Session ID: ${context.sessionId}`);
@ -371,7 +386,7 @@ const injectCustomCode = defineTool({
},
handle: async (context: Context, params: z.output<typeof injectCustomCodeSchema>, response: Response) => {
testDebug('Injecting custom code:', { name: params.name, type: params.type });
if (!context.injectionConfig) {
context.injectionConfig = {
debugToolbar: { enabled: false, minimized: false, showDetails: true, position: 'top-right', theme: 'dark', opacity: 0.9 },
@ -379,7 +394,7 @@ const injectCustomCode = defineTool({
enabled: true
};
}
// Create injection object
const injection: CustomInjection = {
id: `${params.name}_${Date.now()}`,
@ -390,19 +405,19 @@ const injectCustomCode = defineTool({
persistent: params.persistent !== false,
autoInject: params.autoInject !== false
};
// Remove any existing injection with the same name
context.injectionConfig.customInjections = context.injectionConfig.customInjections.filter(
inj => inj.name !== params.name
inj => inj.name !== params.name
);
// Add new injection
context.injectionConfig.customInjections.push(injection);
// Wrap code with LLM-safe markers
const wrappedCode = wrapInjectedCode(injection, context.sessionId, context.injectionConfig.debugToolbar.projectName);
const injectionScript = generateInjectionScript(wrappedCode);
// Inject into current page if available
const currentTab = context.currentTab();
if (currentTab && injection.autoInject) {
@ -414,7 +429,7 @@ const injectCustomCode = defineTool({
testDebug('Error injecting custom code into current page:', error);
}
}
response.addResult(`Custom ${params.type} injection "${params.name}" added successfully`);
response.addResult(`Total injections: ${context.injectionConfig.customInjections.length}`);
response.addResult(`Auto-inject enabled: ${injection.autoInject}`);
@ -432,12 +447,12 @@ const listInjections = defineTool({
},
handle: async (context: Context, params: any, response: Response) => {
const config = context.injectionConfig;
if (!config) {
response.addResult('No injection configuration found');
return;
}
response.addResult(`Session ID: ${context.sessionId}`);
response.addResult(`\nDebug Toolbar:`);
response.addResult(`- Enabled: ${config.debugToolbar.enabled}`);
@ -447,7 +462,7 @@ const listInjections = defineTool({
response.addResult(`- Theme: ${config.debugToolbar.theme}`);
response.addResult(`- Minimized: ${config.debugToolbar.minimized}`);
}
response.addResult(`\nCustom Injections (${config.customInjections.length}):`);
if (config.customInjections.length === 0) {
response.addResult('- None');
@ -471,19 +486,19 @@ const disableDebugToolbar = defineTool({
type: 'destructive',
},
handle: async (context: Context, params: any, response: Response) => {
if (context.injectionConfig) {
if (context.injectionConfig)
context.injectionConfig.debugToolbar.enabled = false;
}
// Remove from current page if available
const currentTab = context.currentTab();
if (currentTab) {
try {
await currentTab.page.evaluate(() => {
const toolbar = document.getElementById('playwright-mcp-debug-toolbar');
if (toolbar) {
if (toolbar)
toolbar.remove();
}
(window as any).playwrightMcpDebugToolbar = false;
});
testDebug('Debug toolbar removed from current page');
@ -491,7 +506,7 @@ const disableDebugToolbar = defineTool({
testDebug('Error removing toolbar from current page:', error);
}
}
response.addResult('Debug toolbar disabled');
}
});
@ -510,22 +525,22 @@ const clearInjections = defineTool({
response.addResult('No injections to clear');
return;
}
const clearedCount = context.injectionConfig.customInjections.length;
context.injectionConfig.customInjections = [];
if (params.includeToolbar) {
context.injectionConfig.debugToolbar.enabled = false;
// Remove toolbar from current page
const currentTab = context.currentTab();
if (currentTab) {
try {
await currentTab.page.evaluate(() => {
const toolbar = document.getElementById('playwright-mcp-debug-toolbar');
if (toolbar) {
if (toolbar)
toolbar.remove();
}
(window as any).playwrightMcpDebugToolbar = false;
});
} catch (error) {
@ -533,7 +548,7 @@ const clearInjections = defineTool({
}
}
}
response.addResult(`Cleared ${clearedCount} custom injections${params.includeToolbar ? ' and disabled debug toolbar' : ''}`);
}
});
@ -544,4 +559,4 @@ export default [
listInjections,
disableDebugToolbar,
clearInjections,
];
];

View file

@ -39,7 +39,7 @@ const configureSchema = z.object({
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional().describe('Preferred color scheme'),
permissions: z.array(z.string()).optional().describe('Permissions to grant (e.g., ["geolocation", "notifications", "camera", "microphone"])'),
offline: z.boolean().optional().describe('Whether to emulate offline network conditions (equivalent to DevTools offline mode)'),
// Browser UI Customization Options
chromiumSandbox: z.boolean().optional().describe('Enable/disable Chromium sandbox (affects browser appearance)'),
slowMo: z.number().min(0).optional().describe('Slow down operations by specified milliseconds (helps with visual tracking)'),

View file

@ -33,7 +33,7 @@ const navigate = defineTool({
handle: async (context, params, response) => {
// Smart recording: Begin action
await context.beginVideoAction('navigate');
const tab = await context.ensureTab();
await tab.navigate(params.url);

View file

@ -54,7 +54,7 @@ const startRecording = defineTool({
// Default video size for better demos
const videoSize = params.size || { width: 1280, height: 720 };
// Update context options to enable video recording
const recordVideoOptions: any = {
dir: videoDir,
@ -62,7 +62,7 @@ const startRecording = defineTool({
};
// Automatically set viewport to match video size for full-frame content
if (params.autoSetViewport !== false) {
if (params.autoSetViewport) {
try {
await context.updateBrowserConfig({
viewport: {
@ -84,19 +84,19 @@ const startRecording = defineTool({
response.addResult(`📁 Videos will be saved to: ${videoDir}`);
response.addResult(`📝 Files will be named: ${baseFilename}-*.webm`);
response.addResult(`📐 Video size: ${videoSize.width}x${videoSize.height}`);
// Show viewport matching info
if (params.autoSetViewport !== false) {
if (params.autoSetViewport) {
response.addResult(`🖼️ Browser viewport matched to video size for full-frame content`);
} else {
response.addResult(`⚠️ Viewport not automatically set - you may see gray borders around content`);
response.addResult(`💡 For full-frame content, use: browser_configure({viewport: {width: ${videoSize.width}, height: ${videoSize.height}}})`);
}
// Show current recording mode
const recordingInfo = context.getVideoRecordingInfo();
response.addResult(`🎯 Recording mode: ${recordingInfo.mode}`);
switch (recordingInfo.mode) {
case 'smart':
response.addResult(`🧠 Smart mode: Auto-pauses during waits, resumes during actions`);
@ -112,7 +112,7 @@ const startRecording = defineTool({
response.addResult(`🎞️ Segment mode: Creating separate files for each action sequence`);
break;
}
response.addResult(`\n📋 Next steps:`);
response.addResult(`1. Navigate to pages and perform browser actions`);
response.addResult(`2. Use browser_stop_recording when finished to save videos`);
@ -179,11 +179,11 @@ const getRecordingStatus = defineTool({
response.addResult('1. Use browser_start_recording to enable recording');
response.addResult('2. Navigate to pages and perform actions');
response.addResult('3. Use browser_stop_recording to save videos');
// Show potential artifact locations for debugging
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager) {
const baseDir = artifactManager.getBaseDirectory();
const sessionDir = artifactManager.getSessionDirectory();
@ -195,7 +195,7 @@ const getRecordingStatus = defineTool({
response.addResult(`\n⚠ No artifact manager configured - videos will save to default output directory`);
response.addResult(`📁 Default output: ${path.join(context.config.outputDir, 'videos')}`);
}
return;
}
@ -209,23 +209,23 @@ const getRecordingStatus = defineTool({
response.addResult(`🎬 Active recordings: ${recordingInfo.activeRecordings}`);
response.addResult(`🎯 Recording mode: ${recordingInfo.mode}`);
if (recordingInfo.paused) {
if (recordingInfo.paused)
response.addResult(`⏸️ Status: PAUSED (${recordingInfo.pausedRecordings} recordings stored)`);
} else {
else
response.addResult(`▶️ Status: RECORDING`);
}
if (recordingInfo.mode === 'segment') {
if (recordingInfo.mode === 'segment')
response.addResult(`🎞️ Current segment: ${recordingInfo.currentSegment}`);
}
// Show helpful path info for MCP clients
const outputDir = recordingInfo.config?.dir;
if (outputDir) {
const absolutePath = path.resolve(outputDir);
response.addResult(`📍 Absolute path: ${absolutePath}`);
// Check if directory exists and show contents
const fs = await import('fs');
if (fs.existsSync(absolutePath)) {
@ -249,7 +249,7 @@ const getRecordingStatus = defineTool({
// Show debug information
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager) {
response.addResult(`\n🔍 Debug Info:`);
response.addResult(`🆔 Session ID: ${context.sessionId}`);
@ -313,13 +313,13 @@ const revealArtifactPaths = defineTool({
const files = items.filter(item => item.isFile()).map(item => item.name);
const dirs = items.filter(item => item.isDirectory()).map(item => item.name);
if (dirs.length > 0) {
if (dirs.length > 0)
response.addResult(`\n📂 Existing subdirectories: ${dirs.join(', ')}`);
}
if (files.length > 0) {
if (files.length > 0)
response.addResult(`📄 Files in session directory: ${files.join(', ')}`);
}
// Count .webm files across all subdirectories
let webmCount = 0;
@ -328,11 +328,11 @@ const revealArtifactPaths = defineTool({
const contents = fs.readdirSync(dir, { withFileTypes: true });
for (const item of contents) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
if (item.isDirectory())
countWebmFiles(fullPath);
} else if (item.name.endsWith('.webm')) {
else if (item.name.endsWith('.webm'))
webmCount++;
}
}
} catch (error) {
// Ignore permission errors
@ -340,9 +340,9 @@ const revealArtifactPaths = defineTool({
}
countWebmFiles(sessionDir);
if (webmCount > 0) {
if (webmCount > 0)
response.addResult(`🎬 Total .webm video files found: ${webmCount}`);
}
} catch (error: any) {
response.addResult(`⚠️ Could not list session directory contents: ${error.message}`);
}
@ -383,9 +383,9 @@ const pauseRecording = defineTool({
handle: async (context, params, response) => {
const result = await context.pauseVideoRecording();
response.addResult(`⏸️ ${result.message}`);
if (result.paused > 0) {
if (result.paused > 0)
response.addResult(`💡 Use browser_resume_recording to continue`);
}
},
});
@ -421,9 +421,9 @@ const setRecordingMode = defineTool({
handle: async (context, params, response) => {
context.setVideoRecordingMode(params.mode);
response.addResult(`🎬 Video recording mode set to: ${params.mode}`);
switch (params.mode) {
case 'continuous':
response.addResult('📹 Will record everything continuously (traditional behavior)');
@ -441,7 +441,7 @@ const setRecordingMode = defineTool({
response.addResult('💡 Useful for breaking demos into individual clips');
break;
}
const recordingInfo = context.getVideoRecordingInfo();
if (recordingInfo.enabled) {
response.addResult(`\n🎥 Current recording status: ${recordingInfo.paused ? 'paused' : 'active'}`);

View file

@ -39,8 +39,8 @@ const wait = defineTool({
// Handle smart recording for waits
const recordingInfo = context.getVideoRecordingInfo();
const shouldPauseDuringWait = recordingInfo.enabled &&
recordingInfo.mode !== 'continuous' &&
const shouldPauseDuringWait = recordingInfo.enabled &&
recordingInfo.mode !== 'continuous' &&
!params.recordDuringWait;
if (shouldPauseDuringWait) {
@ -76,9 +76,9 @@ const wait = defineTool({
}
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
if (params.recordDuringWait && recordingInfo.enabled) {
if (params.recordDuringWait && recordingInfo.enabled)
response.addResult(`🎥 Video recording continued during wait`);
}
response.setIncludeSnapshot();
},
});