Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:18:56 +08:00
commit 3aef0f6c84
70 changed files with 14222 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
export default [
{
ignores: [
'node_modules/**',
'dist/**',
'*.backup/**'
]
},
{
files: ['src/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json'
}
},
plugins: {
'@typescript-eslint': typescriptEslint
},
rules: {
// TypeScript 규칙
'@typescript-eslint/no-explicit-any': 'error', // any 사용 금지 - 타입 가드 또는 명시적 타입 사용
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
// 일반 규칙
'no-console': 'off', // CLI 도구이므로 console 사용 허용
'prefer-const': 'error',
'no-var': 'error'
}
}
];

View File

@@ -0,0 +1,47 @@
{
"name": "browser-pilot-cli",
"version": "1.10.0",
"description": "Chrome DevTools Protocol browser automation CLI",
"main": "dist/cli/cli.js",
"bin": {
"cdp-browser": "./dist/cli/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"type-check": "tsc --noEmit",
"bp:run": "node dist/cli/cli.js",
"bp:chain": "node dist/cli/cli.js chain",
"bp:daemon-start": "node dist/cli/cli.js daemon-start",
"bp:daemon-stop": "node dist/cli/cli.js daemon-stop",
"bp:daemon-restart": "node dist/cli/cli.js daemon-restart",
"bp:daemon-status": "node dist/cli/cli.js daemon-status",
"bp:query": "node dist/cli/cli.js query",
"bp:regen-map": "node dist/cli/cli.js regen-map",
"bp:map-status": "node dist/cli/cli.js map-status"
},
"keywords": [
"cdp",
"chrome",
"automation",
"browser",
"scraping"
],
"author": "",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^24.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^9.39.1",
"typescript": "^5.9.3"
},
"dependencies": {
"commander": "^14.0.2",
"ws": "^8.18.3"
}
}

View File

@@ -0,0 +1,28 @@
/**
* Core CDP actions for browser automation.
*
* This file serves as the main index for all action modules.
* Modularized for better organization and maintainability.
*/
// Export ActionResult type
export type { ActionResult } from './actions/helpers';
// Re-export helper functions (excluding ActionResult to avoid conflict)
export { sleep, checkConsoleErrors, ensureOutputPath } from './actions/helpers';
// Re-export modular actions
export * from './actions/navigation';
export * from './actions/interaction';
export * from './actions/capture';
export * from './actions/data';
export * from './actions/cookies';
export * from './actions/tabs';
export * from './actions/forms';
export * from './actions/input';
export * from './actions/scroll';
export * from './actions/wait';
export * from './actions/debugging';
export * from './actions/emulation';
export * from './actions/dialogs';
export * from './actions/network';

View File

@@ -0,0 +1,179 @@
/**
* Capture actions (screenshot, PDF) for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { ActionResult, ActionOptions, mergeOptions, ensureOutputPath } from './helpers';
import { logger } from '../../utils/logger';
import { FS } from '../../constants';
// CDP Types for Page domain
interface LayoutMetrics {
contentSize: {
width: number;
height: number;
};
}
interface ScreenshotParams {
clip?: {
x: number;
y: number;
width: number;
height: number;
scale: number;
};
}
interface ScreenshotResult {
data: string;
}
interface PDFParams {
printBackground: boolean;
landscape: boolean;
paperWidth: number;
paperHeight: number;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
}
interface PDFResult {
data: string;
}
// PDF Constants
const PDF_PAPER_LETTER_WIDTH = 8.5; // inches
const PDF_PAPER_LETTER_HEIGHT = 11.0; // inches
const PDF_DEFAULT_MARGIN = 0.4; // inches
export interface ClipOptions {
x: number;
y: number;
width: number;
height: number;
scale?: number;
}
/**
* Take screenshot.
* @param browser - ChromeBrowser instance
* @param filename - Screenshot filename (automatically saved to .browser-pilot/screenshots/)
* @param fullPage - Capture full page or viewport only
* @param clip - Optional clip region (x, y, width, height, scale)
* @param options - Action options
*/
export async function screenshot(
browser: ChromeBrowser,
filename: string,
fullPage = true,
clip?: ClipOptions,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
// Construct path within screenshots folder
const screenshotPath = join(FS.SCREENSHOTS_DIR, filename);
if (opts.verbose) logger.info(`📸 Taking screenshot: ${screenshotPath}`);
// Enable Page domain
await browser.sendCommand('Page.enable');
let params: ScreenshotParams = {};
// Clip region has priority over fullPage
if (clip) {
params = {
clip: {
x: clip.x,
y: clip.y,
width: clip.width,
height: clip.height,
scale: clip.scale || 1
}
};
if (opts.verbose) {
logger.info(` Region: (${clip.x}, ${clip.y}) ${clip.width}x${clip.height} scale=${clip.scale || 1}`);
}
} else if (fullPage) {
// Get page dimensions
const metrics = await browser.sendCommand<LayoutMetrics>('Page.getLayoutMetrics');
const contentSize = metrics.contentSize;
params = {
clip: {
x: 0,
y: 0,
width: contentSize.width,
height: contentSize.height,
scale: 1
}
};
}
const result = await browser.sendCommand<ScreenshotResult>('Page.captureScreenshot', params);
// Decode and save
const imageData = Buffer.from(result.data, 'base64');
// Ensure output directory exists (creates .browser-pilot/screenshots/ if needed)
const absolutePath = ensureOutputPath(screenshotPath);
writeFileSync(absolutePath, imageData);
if (opts.verbose) logger.info(`✅ Screenshot saved: ${absolutePath}`);
return { success: true, path: absolutePath };
}
/**
* Generate PDF from current page.
* @param browser - ChromeBrowser instance
* @param filename - PDF filename (automatically saved to .browser-pilot/pdfs/)
* @param landscape - Use landscape orientation
* @param printBackground - Print background graphics
* @param options - Action options
*/
export async function generatePdf(
browser: ChromeBrowser,
filename: string,
landscape = false,
printBackground = true,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
// Construct path within pdfs folder
const pdfPath = join(FS.PDFS_DIR, filename);
if (opts.verbose) logger.info(`📄 Generating PDF: ${pdfPath}`);
await browser.sendCommand('Page.enable');
const params: PDFParams = {
printBackground,
landscape,
paperWidth: PDF_PAPER_LETTER_WIDTH,
paperHeight: PDF_PAPER_LETTER_HEIGHT,
marginTop: PDF_DEFAULT_MARGIN,
marginBottom: PDF_DEFAULT_MARGIN,
marginLeft: PDF_DEFAULT_MARGIN,
marginRight: PDF_DEFAULT_MARGIN
};
const result = await browser.sendCommand<PDFResult>('Page.printToPDF', params);
const pdfData = Buffer.from(result.data, 'base64');
// Ensure output directory exists (creates .browser-pilot/pdfs/ if needed)
const absolutePath = ensureOutputPath(pdfPath);
writeFileSync(absolutePath, pdfData);
if (opts.verbose) logger.info(`✅ PDF saved: ${absolutePath}`);
return { success: true, path: absolutePath };
}

View File

@@ -0,0 +1,122 @@
/**
* Cookie management actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
// CDP Types for Network.Cookie
interface Cookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: number;
size?: number;
httpOnly?: boolean;
secure?: boolean;
session?: boolean;
sameSite?: string;
}
interface GetCookiesResult {
cookies: Cookie[];
}
interface SetCookieParams {
name: string;
value: string;
domain?: string;
path: string;
secure: boolean;
httpOnly: boolean;
expires?: number;
sameSite?: string;
}
/**
* Get all cookies.
*/
export async function getCookies(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('🍪 Getting cookies...');
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
const cookies = result.cookies || [];
if (opts.verbose) logger.info(`✅ Retrieved ${cookies.length} cookie(s)`);
return { success: true, cookies, count: cookies.length };
}
/**
* Set a cookie.
*/
export async function setCookie(
browser: ChromeBrowser,
name: string,
value: string,
domain?: string,
path = '/',
secure = false,
httpOnly = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🍪 Setting cookie: ${name}`);
const cookieParams: SetCookieParams = {
name,
value,
path,
secure,
httpOnly,
...(domain && { domain })
};
await browser.sendCommand('Network.setCookie', cookieParams);
if (opts.verbose) logger.info(`✅ Cookie set successfully`);
return { success: true, name };
}
/**
* Delete cookies.
*/
export async function deleteCookies(
browser: ChromeBrowser,
name?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (name) {
if (opts.verbose) logger.info(`🍪 Deleting cookie: ${name}`);
// Get all cookies to find the domain
const result = await browser.sendCommand<GetCookiesResult>('Network.getCookies');
const cookies = result.cookies || [];
// Find matching cookies
const matchingCookies = cookies.filter((c: Cookie) => c.name === name);
if (matchingCookies.length > 0) {
for (const cookie of matchingCookies) {
await browser.sendCommand('Network.deleteCookies', {
name,
domain: cookie.domain || ''
});
}
if (opts.verbose) logger.info(`✅ Deleted ${matchingCookies.length} cookie(s) with name '${name}'`);
} else {
if (opts.verbose) logger.warn(`⚠️ Warning: Cookie '${name}' not found`);
}
} else {
if (opts.verbose) logger.info('🍪 Deleting all cookies...');
await browser.sendCommand('Network.clearBrowserCookies');
if (opts.verbose) logger.info(`✅ All cookies deleted`);
}
return { success: true };
}

View File

@@ -0,0 +1,207 @@
/**
* Data extraction and evaluation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Evaluate JavaScript.
*/
export async function evaluate(
browser: ChromeBrowser,
script: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⚙️ Evaluating JavaScript...`);
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Evaluation complete`);
checkErrors(browser, opts.logLevel);
return { success: true, result: result.result?.value };
}
/**
* Extract text from element or body.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function extractText(
browser: ChromeBrowser,
selector?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (selector) {
logger.info(`📝 Extracting text from: ${selector}`);
} else {
logger.info(`📝 Extracting text from page body`);
}
}
const script = selector
? `(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
return findElement(selector)?.textContent || '';
})()`
: `document.body.textContent || ''`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const text = (result.result?.value as string) || '';
if (opts.verbose) logger.info(`✅ Extracted ${text.length} characters`);
checkErrors(browser, opts.logLevel);
return { success: true, text };
}
/**
* Extract data using multiple selectors.
*/
export async function extractData(
browser: ChromeBrowser,
selectors: Record<string, string>,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📊 Extracting data with ${Object.keys(selectors).length} selectors`);
const data: Record<string, unknown> = {};
for (const [key, selector] of Object.entries(selectors)) {
try {
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const elements = document.querySelectorAll(selector);
if (elements.length === 0) return null;
if (elements.length === 1) return elements[0].innerText;
return Array.from(elements).map(el => el.innerText);
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
data[key] = result.result?.value;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
data[key] = `Error: ${errorMessage}`;
}
}
if (opts.verbose) logger.info(`✅ Extracted data for ${Object.keys(data).length} keys`);
checkErrors(browser, opts.logLevel);
return { success: true, data };
}
/**
* Get page HTML content.
*/
export async function getContent(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('📄 Getting page HTML content');
const script = `document.documentElement.outerHTML`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const content = (result.result?.value as string) || '';
if (opts.verbose) logger.info(`✅ Retrieved ${content.length} characters of HTML`);
return {
success: true,
content,
length: content.length
};
}
/**
* Get element property value.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function getElementProperty(
browser: ChromeBrowser,
selector: string,
propertyName: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Getting property '${propertyName}' from: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const propertyName = ${JSON.stringify(propertyName)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
return el[propertyName];
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (result.exceptionDetails) {
const errorMsg = result.exceptionDetails.exception?.description ||
result.exceptionDetails.text ||
'Unknown error';
if (opts.verbose) {
logger.error(`❌ Get property failed: ${selector}`);
logger.error(` Error: ${errorMsg}`);
}
return {
success: false,
error: errorMsg
};
}
if (opts.verbose) logger.info(`✅ Property '${propertyName}': ${result.result?.value}`);
checkErrors(browser, opts.logLevel);
return {
success: true,
selector,
property: propertyName,
value: result.result?.value
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Get property failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
return {
success: false,
error: errorMessage
};
}
}

View File

@@ -0,0 +1,165 @@
/**
* Debugging actions for Browser Pilot.
*/
import { ChromeBrowser, FormattedConsoleMessage } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Get console messages.
*
* Returns console messages that have been collected since the browser connected.
* Messages are automatically collected when Log domain is enabled during connection.
*/
export async function getConsoleMessages(
browser: ChromeBrowser,
errorOnly = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('📋 Getting console messages...');
// Get all collected messages from browser
const allMessages = browser.getConsoleMessages();
// Filter by error level if requested
const messages = errorOnly
? allMessages.filter(msg => msg.level === 'error')
: allMessages;
// Format messages for display
const formattedMessages: FormattedConsoleMessage[] = messages.map(msg => ({
level: msg.level,
text: msg.text,
timestamp: new Date(msg.timestamp).toISOString(),
url: msg.url,
lineNumber: msg.lineNumber
}));
const errorCount = allMessages.filter(msg => msg.level === 'error').length;
const warningCount = allMessages.filter(msg => msg.level === 'warning').length;
if (opts.verbose) {
logger.info(`✅ Retrieved ${formattedMessages.length} message(s) (${errorCount} errors, ${warningCount} warnings)`);
}
return {
success: true,
messages: formattedMessages,
count: formattedMessages.length,
errorCount,
warningCount,
logCount: allMessages.filter(msg => msg.level === 'log' || msg.level === 'info').length
};
}
/**
* Get accessibility tree snapshot.
*/
export async function getAccessibilitySnapshot(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info('♿ Getting accessibility snapshot...');
try {
await browser.sendCommand('Accessibility.enable');
const result = await browser.sendCommand<{ nodes: unknown[] }>('Accessibility.getFullAXTree');
const nodes = result.nodes || [];
const formattedNodes = nodes.slice(0, 50).map((node: unknown) => {
const n = node as { role?: { value?: string }; name?: { value?: string }; description?: { value?: string } };
return {
role: n.role?.value,
name: n.name?.value,
description: n.description?.value
};
});
if (opts.verbose) logger.info(`✅ Retrieved ${nodes.length} accessibility nodes (showing first 50)`);
return {
success: true,
nodeCount: nodes.length,
nodes: formattedNodes
};
} catch (error) {
if (opts.verbose) {
logger.error(`❌ Get accessibility snapshot failed`, error);
}
throw error;
}
}
/**
* Find element and return its information.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function findElement(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
tagName: el.tagName.toLowerCase(),
id: el.id,
className: el.className,
textContent: el.textContent?.substring(0, 100),
visible: rect.width > 0 && rect.height > 0,
position: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
},
attributes: Array.from(el.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {})
};
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const elementInfo = result.result?.value as { tagName?: string; id?: string; className?: string; attributes?: Record<string, string> } | null | undefined;
if (!elementInfo) {
if (opts.verbose) logger.info(`❌ Element not found: ${selector}`);
return {
success: false,
error: `Element not found: ${selector}`
};
}
if (opts.verbose) logger.info(`✅ Found <${elementInfo.tagName}> element`);
checkErrors(browser, opts.logLevel);
return {
success: true,
selector,
element: elementInfo
};
}

View File

@@ -0,0 +1,141 @@
/**
* Dialog handling actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Handle JavaScript dialogs (alert, confirm, prompt).
* Must be called BEFORE the dialog appears.
*/
export async function handleDialog(
browser: ChromeBrowser,
accept: boolean = true,
promptText?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`💬 Setting up dialog handler - accept: ${accept}, promptText: ${promptText || 'none'}`);
}
try {
// Enable Page domain for dialog events
await browser.sendCommand('Page.enable');
// Set up dialog handler
await browser.sendCommand('Page.setInterceptFileChooserDialog', {
enabled: false
});
// Note: CDP doesn't have a way to pre-register dialog handlers
// This returns a handler configuration that should be used with Page.javascriptDialogOpening event
if (opts.verbose) logger.info(`✅ Dialog handler configured`);
return {
success: true,
accept,
promptText: promptText || null,
note: 'Dialog handler configured. Use getDialogMessage() to check for dialogs.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Dialog handler setup failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Get current dialog message if one is open.
* This should be called in response to Page.javascriptDialogOpening event.
*/
export async function getDialogMessage(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`💬 Checking for dialog...`);
// This function is a placeholder for dialog detection
// In real CDP usage, you'd listen for Page.javascriptDialogOpening events
const script = `
(function() {
// Check if there's an active dialog by trying to access document
try {
document.body;
return null; // No dialog
} catch (e) {
return { blocked: true }; // Dialog is blocking
}
})()
`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
const dialogActive = result.result?.value !== null;
if (opts.verbose) {
logger.info(dialogActive ? `⚠️ Dialog is active` : `✅ No dialog active`);
}
return {
success: true,
dialogActive
};
}
/**
* Accept or dismiss a JavaScript dialog.
*/
export async function respondToDialog(
browser: ChromeBrowser,
accept: boolean = true,
promptText?: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`💬 Responding to dialog - accept: ${accept}`);
try {
await browser.sendCommand('Page.handleJavaScriptDialog', {
accept,
promptText: promptText || ''
});
if (opts.verbose) logger.info(`✅ Dialog ${accept ? 'accepted' : 'dismissed'}`);
return {
success: true,
accept,
promptText: promptText || null
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Respond to dialog failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,184 @@
/**
* Emulation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, logActionError } from './helpers';
import { logger } from '../../utils/logger';
export interface ViewportOptions {
width: number;
height: number;
deviceScaleFactor?: number;
mobile?: boolean;
}
/**
* Emulate media type or color scheme.
*/
export async function emulateMedia(
browser: ChromeBrowser,
mediaType?: 'screen' | 'print',
colorScheme?: 'light' | 'dark' | 'no-preference',
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`🎨 Emulating media - type: ${mediaType || 'none'}, colorScheme: ${colorScheme || 'none'}`);
}
try {
await browser.sendCommand('Emulation.setEmulatedMedia', {
media: mediaType || '',
features: colorScheme ? [{
name: 'prefers-color-scheme',
value: colorScheme
}] : []
});
if (opts.verbose) logger.info(`✅ Media emulation set`);
return {
success: true,
mediaType: mediaType || null,
colorScheme: colorScheme || null
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Emulate media failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Set viewport size.
* @param browser - ChromeBrowser instance
* @param width - Viewport width in pixels
* @param height - Viewport height in pixels
* @param deviceScaleFactor - Device scale factor (default: 1)
* @param mobile - Whether to emulate mobile device (default: false)
* @param options - Action options
*/
export async function setViewportSize(
browser: ChromeBrowser,
width: number,
height: number,
deviceScaleFactor = 1,
mobile = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📐 Setting viewport size: ${width}x${height} (scale: ${deviceScaleFactor}, mobile: ${mobile})`);
}
try {
await browser.sendCommand('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor,
mobile
});
if (opts.verbose) logger.info(`✅ Viewport size set to ${width}x${height}`);
return {
success: true,
width,
height,
deviceScaleFactor,
mobile
};
} catch (error: unknown) {
logActionError('Set viewport size failed', error, opts.verbose);
throw error;
}
}
/**
* Get current viewport size.
* @param browser - ChromeBrowser instance
* @param options - Action options
*/
export async function getViewport(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📏 Getting viewport size...`);
}
try {
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: 'JSON.stringify({width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio})',
returnByValue: true
});
const viewport = JSON.parse(result.result.value as string);
if (opts.verbose) {
logger.info(`✅ Viewport: ${viewport.width}x${viewport.height} (scale: ${viewport.devicePixelRatio})`);
}
return {
success: true,
viewport
};
} catch (error: unknown) {
logActionError('Get viewport failed', error, opts.verbose);
throw error;
}
}
/**
* Get screen and viewport information.
* @param browser - ChromeBrowser instance
* @param options - Action options
*/
export async function getScreenInfo(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`📊 Getting screen information...`);
}
try {
const result = await browser.sendCommand<{ result: { value: unknown } }>('Runtime.evaluate', {
expression: 'JSON.stringify({viewport: {width: window.innerWidth, height: window.innerHeight}, screen: {width: window.screen.width, height: window.screen.height, availWidth: window.screen.availWidth, availHeight: window.screen.availHeight}, devicePixelRatio: window.devicePixelRatio})',
returnByValue: true
});
const screenInfo = JSON.parse(result.result.value as string);
if (opts.verbose) {
logger.info(`✅ Screen: ${screenInfo.screen.width}x${screenInfo.screen.height}`);
logger.info(` Viewport: ${screenInfo.viewport.width}x${screenInfo.viewport.height}`);
logger.info(` Scale: ${screenInfo.devicePixelRatio}`);
}
return {
success: true,
...screenInfo
};
} catch (error: unknown) {
logActionError('Get screen info failed', error, opts.verbose);
throw error;
}
}

View File

@@ -0,0 +1,259 @@
/**
* Form actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { readFileSync, statSync } from 'fs';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Select option from dropdown.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function selectOption(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔽 Selecting option ${value} in: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const value = ${JSON.stringify(value)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Selected option: ${value}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector, value };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Select failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Helper function to toggle checkbox state.
* @param browser - ChromeBrowser instance
* @param selector - Checkbox selector
* @param targetState - Desired checkbox state (true = checked, false = unchecked)
* @param actionName - Action name for logging ("check" or "uncheck")
* @param options - Action options
*/
async function _toggleCheckbox(
browser: ChromeBrowser,
selector: string,
targetState: boolean,
actionName: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
const emoji = targetState ? '☑️' : '☐';
const stateName = targetState ? 'checked' : 'unchecked';
if (opts.verbose) logger.info(`${emoji} ${actionName}: ${selector}`);
// Step 1: Find element and get coordinates
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
if (el.type !== 'checkbox') throw new Error('Element is not a checkbox: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
checked: el.checked
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, checked: isChecked } = result.result.value as { x: number; y: number; checked: boolean };
// Step 2: Click only if current state differs from target state
if (isChecked === targetState) {
if (opts.verbose) logger.info(`✓ Checkbox already ${stateName}`);
} else {
if (opts.verbose) logger.info(`🖱️ Clicking checkbox at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
}
if (opts.verbose) logger.info(`✅ Checkbox ${stateName}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`${actionName} failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Check checkbox.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP click for proper React compatibility.
*/
export async function check(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
return _toggleCheckbox(browser, selector, true, 'Checking', options);
}
/**
* Uncheck checkbox.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP click for proper React compatibility.
*/
export async function uncheck(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
return _toggleCheckbox(browser, selector, false, 'Unchecking', options);
}
/**
* Upload file to input element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function uploadFile(
browser: ChromeBrowser,
selector: string,
filePath: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📁 Uploading file ${filePath} to: ${selector}`);
// File size validation (10MB limit)
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const stats = statSync(filePath);
if (stats.size > MAX_FILE_SIZE) {
const error = `File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE} bytes = 10MB)`;
if (opts.verbose) logger.error(`${error}`);
throw new Error(error);
}
const fileData = readFileSync(filePath, 'base64');
const fileName = filePath.split(/[/\\]/).pop() || 'file';
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
const fileData = ${JSON.stringify(fileData)};
const fileName = ${JSON.stringify(fileName)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
if (el.tagName !== 'INPUT' || el.type !== 'file') {
throw new Error('Element is not a file input');
}
const dataTransfer = new DataTransfer();
const file = new File(
[Uint8Array.from(atob(fileData), c => c.charCodeAt(0))],
fileName
);
dataTransfer.items.add(file);
el.files = dataTransfer.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ File uploaded: ${fileName}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector, file: filePath };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Upload failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,225 @@
/**
* Helper functions for Browser Pilot actions.
*/
import { ChromeBrowser } from '../browser';
import { resolve, dirname } from 'path';
import { mkdirSync, existsSync } from 'fs';
import { getOutputDir } from '../config';
import { waitForNetworkIdle } from './wait';
import { logger } from '../../utils/logger';
import { TIMING, FS } from '../../constants';
// ActionResult interface - will be exported from main actions.ts
interface ActionResult {
success: boolean;
[key: string]: unknown;
}
// Export for internal use within actions modules
export type { ActionResult };
/**
* CDP Runtime.evaluate response type
*/
export interface RuntimeEvaluateResult {
result?: {
type?: string;
value?: unknown;
description?: string;
};
exceptionDetails?: {
exception?: {
description?: string;
};
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
};
}
/**
* Log level for error and warning reporting
*/
export type LogLevel = 'all' | 'errors-only' | 'none';
/**
* Constants for error checking and timing
*/
const RECENT_MESSAGE_TIMEOUT_MS = TIMING.RECENT_MESSAGE_WINDOW;
const NAVIGATION_WAIT_DELAY_MS = TIMING.NETWORK_IDLE_TIMEOUT;
/**
* Constants for selector retry logic
*/
export const SELECTOR_RETRY_CONFIG = {
MAX_ATTEMPTS: 3,
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
MAP_FOLDER: FS.OUTPUT_DIR
} as const;
/**
* Action options interface
*/
export interface ActionOptions {
verbose?: boolean; // Enable/disable logging (default: true)
logLevel?: LogLevel; // Log level for errors/warnings (default: 'all')
waitForNavigation?: boolean; // Wait for page navigation after action (default: false)
}
/**
* Default action options
*/
export const DEFAULT_OPTIONS: ActionOptions = {
verbose: true,
logLevel: 'all',
waitForNavigation: false
};
/**
* Helper: Merge user options with defaults
*/
export function mergeOptions(options?: ActionOptions): Required<ActionOptions> {
return {
verbose: options?.verbose ?? (DEFAULT_OPTIONS.verbose as boolean),
logLevel: options?.logLevel ?? (DEFAULT_OPTIONS.logLevel as LogLevel),
waitForNavigation: options?.waitForNavigation ?? (DEFAULT_OPTIONS.waitForNavigation as boolean)
};
}
/**
* Helper: Sleep for specified milliseconds.
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Helper: Check browser console and network for errors and warnings after an action.
* @param browser - ChromeBrowser instance
* @param logLevel - Log level ('all', 'errors-only', 'none')
*/
export function checkErrors(browser: ChromeBrowser, logLevel: LogLevel = 'all'): void {
if (logLevel === 'none') {
return; // Skip logging
}
const messages = browser.getConsoleMessages();
const networkErrors = browser.getNetworkErrors();
// Filter for errors and warnings from recent messages
const recentMessages = messages.filter(msg => {
const age = Date.now() - msg.timestamp;
return age < RECENT_MESSAGE_TIMEOUT_MS;
});
const recentNetworkErrors = networkErrors.filter(err => {
const age = Date.now() - err.timestamp;
return age < RECENT_MESSAGE_TIMEOUT_MS;
});
const consoleErrors = recentMessages.filter(msg => msg.level === 'error');
const consoleWarnings = recentMessages.filter(msg => msg.level === 'warning');
// Console Errors
if (consoleErrors.length > 0) {
logger.error(`\n❌ ${consoleErrors.length} console error(s) detected:`);
consoleErrors.forEach((err, idx) => {
logger.error(` ${idx + 1}. ${err.text}`);
if (err.url) {
logger.error(` at ${err.url}:${err.lineNumber || 0}`);
}
});
}
// Console Warnings (only if logLevel is 'all')
if (logLevel === 'all' && consoleWarnings.length > 0) {
logger.warn(`\n⚠ ${consoleWarnings.length} console warning(s) detected:`);
consoleWarnings.forEach((warn, idx) => {
logger.warn(` ${idx + 1}. ${warn.text}`);
});
}
// Network Errors
if (recentNetworkErrors.length > 0) {
logger.error(`\n🌐 ${recentNetworkErrors.length} network error(s) detected:`);
recentNetworkErrors.forEach((err, idx) => {
logger.error(` ${idx + 1}. ${err.url}`);
logger.error(` ${err.errorText}`);
if (err.statusCode) {
logger.error(` Status: ${err.statusCode}`);
}
});
}
}
/**
* @deprecated Use checkErrors instead
*/
export function checkConsoleErrors(browser: ChromeBrowser): void {
checkErrors(browser, 'all');
}
/**
* Helper: Wait for action completion (navigation + errors check).
* Reduces code duplication across click, fill, and other interactive actions.
*/
export async function waitForActionComplete(
browser: ChromeBrowser,
opts: Required<ActionOptions>
): Promise<void> {
if (opts.waitForNavigation) {
if (opts.verbose) logger.info(`⏳ Waiting for page navigation...`);
await waitForNetworkIdle(browser, TIMING.ACTION_DELAY_NAVIGATION, 0, { verbose: false });
await sleep(NAVIGATION_WAIT_DELAY_MS); // Additional delay for errors to surface
}
checkErrors(browser, opts.logLevel);
}
/**
* Helper: Log action error with consistent formatting
* @param context - Error context (e.g., 'Get viewport failed')
* @param error - Error object
* @param verbose - Whether to log the error
*/
export function logActionError(context: string, error: unknown, verbose: boolean): void {
if (!verbose) return;
logger.error(`${context}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
/**
* Helper: Ensure output path (convert relative to .browser-pilot/).
* Security: Prevents path traversal attacks and rejects absolute paths.
* Uses getOutputDir() from config to get project-specific output directory.
*/
export function ensureOutputPath(path: string): string {
// Reject absolute paths
if (resolve(path) === path) {
throw new Error('Absolute paths are not allowed. Use relative paths only.');
}
// Get output directory from project config (auto-creates .browser-pilot/)
const outputDir = getOutputDir();
const absolutePath = resolve(outputDir, path);
// Prevent path traversal attacks
if (!absolutePath.startsWith(outputDir)) {
throw new Error('Path traversal detected. Files must be within .browser-pilot directory.');
}
// Ensure subdirectory exists (if path includes subdirectories)
const dir = dirname(absolutePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return absolutePath;
}

View File

@@ -0,0 +1,106 @@
/**
* Keyboard input actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
import { logger } from '../../utils/logger';
/**
* Press keyboard key.
* Uses CDP Input.dispatchKeyEvent for proper React compatibility.
* Supports special keys like 'Enter', 'Escape', 'Tab', etc.
*/
export async function pressKey(
browser: ChromeBrowser,
key: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⌨️ Pressing key: ${key}`);
try {
// Send keyDown event
await browser.sendCommand('Input.dispatchKeyEvent', {
type: 'keyDown',
key: key
});
// Send keyUp event
await browser.sendCommand('Input.dispatchKeyEvent', {
type: 'keyUp',
key: key
});
if (opts.verbose) logger.info(`✅ Key pressed: ${key}`);
checkErrors(browser, opts.logLevel);
return { success: true, key };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Press key failed: ${key}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
checkErrors(browser, opts.logLevel);
throw error;
}
}
/**
* Type text character by character.
* Uses CDP Input.insertText for proper React compatibility.
* Supports delay between characters for typing simulation.
*/
export async function typeText(
browser: ChromeBrowser,
text: string,
delay = 0,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`⌨️ Typing: "${text}"`);
if (delay > 0) logger.info(` Delay: ${delay}ms per character`);
}
try {
if (delay > 0) {
// Type character by character with delay using CDP
for (const char of text) {
await browser.sendCommand('Input.insertText', {
text: char
});
await sleep(delay);
}
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters with ${delay}ms delay`);
} else {
// Type all at once using CDP
await browser.sendCommand('Input.insertText', {
text: text
});
if (opts.verbose) logger.info(`✅ Typed ${text.length} characters`);
}
checkErrors(browser, opts.logLevel);
return { success: true, text };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Type text failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,712 @@
/**
* Interaction actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, waitForActionComplete, sleep, RuntimeEvaluateResult, SELECTOR_RETRY_CONFIG } from './helpers';
import { findSelectorWithFallback } from '../map/query-map';
import { existsSync } from 'fs';
import { join } from 'path';
import { logger } from '../../utils/logger';
import { TIMING } from '../../constants';
/**
* Click element core logic (without retry).
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
*/
async function clickCore(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Finding element: ${selector}`);
// Step 1: Find element and scroll into view
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
text: el.textContent?.substring(0, 50) || '',
visible: box.width > 0 && box.height > 0
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
}
// Step 2: Dispatch CDP mouse events (Puppeteer way)
if (opts.verbose) logger.info(`🖱️ Mouse down at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
if (opts.verbose) logger.info(`🖱️ Mouse up at (${Math.round(x)}, ${Math.round(y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
if (opts.verbose) logger.info(`✅ Clicked: ${selector}`);
// Wait for navigation and check errors
await waitForActionComplete(browser, opts);
return {
success: true,
selector,
coordinates: { x: Math.round(x), y: Math.round(y) },
element: { tag, text }
};
} catch (error: unknown) {
if (opts.verbose) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`❌ Click failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Click element with automatic retry using interaction map fallback.
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//button[text()='Click'])[2] selects the 2nd button.
*
* On failure, attempts to find alternative selectors from interaction map and retries.
*/
export async function click(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
try {
// First attempt with provided selector
return await clickCore(browser, selector, options);
} catch (error: unknown) {
// Check if map file exists
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
if (!existsSync(mapPath)) {
if (opts.verbose) {
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
}
throw error;
}
if (opts.verbose) {
logger.info('🔄 Attempting to find alternative selectors from map...');
}
// Try to find alternative selectors
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
try {
// If original selector looks like an ID, try querying by ID
if (selector.startsWith('#')) {
const id = selector.slice(1);
fallbackResult = findSelectorWithFallback(mapPath, { id });
}
// If selector contains text in XPath format, extract and search
else if (selector.includes('contains(text()')) {
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
if (textMatch && textMatch[1]) {
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
}
}
} catch (mapError: unknown) {
if (opts.verbose) {
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
}
throw error; // Rethrow original error
}
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
if (opts.verbose) {
logger.info('⚠️ No alternative selectors found in map.');
}
throw error; // Rethrow original error
}
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
const maxRetries = Math.min(
fallbackResult.alternatives.length,
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
);
for (let i = 0; i < maxRetries; i++) {
const altSelector = fallbackResult.alternatives[i];
if (opts.verbose) {
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
}
try {
return await clickCore(browser, altSelector, options);
} catch (_retryError: unknown) {
if (i === maxRetries - 1) {
// Last retry failed, throw original error
if (opts.verbose) {
logger.info('❌ All retry attempts exhausted.');
}
throw error;
}
// Continue to next alternative
}
}
// Should not reach here, but throw original error as fallback
throw error;
}
}
/**
* Fill input field core logic (without retry).
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
* Uses CDP click + insertText for proper React compatibility.
*/
async function fillCore(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
logger.info(`✍️ Filling input: ${selector}`);
logger.info(` Value: "${value}"`);
}
// Step 1: Find element, get coordinates, and clear existing value
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
// Clear existing value
el.value = '';
el.dispatchEvent(new Event('input', { bubbles: true }));
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
type: el.type || 'text'
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, type } = result.result.value as { x: number; y: number; tag: string; type: string };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()} type="${type}">`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)})`);
}
// Step 2: Click to focus
if (opts.verbose) logger.info(`🖱️ Clicking to focus...`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x,
y
});
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x,
y
});
// Small delay to ensure focus
await sleep(TIMING.ACTION_DELAY_SHORT);
// Step 3: Insert text using CDP
if (opts.verbose) logger.info(`⌨️ Inserting text: "${value}"`);
await browser.sendCommand('Input.insertText', {
text: value
});
if (opts.verbose) logger.info(`✅ Fill successful`);
// Wait for navigation and check errors
await waitForActionComplete(browser, opts);
return { success: true, selector, value };
} catch (error: unknown) {
if (opts.verbose) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`❌ Fill failed: ${selector}`);
logger.error(` Error: ${errorMessage}`);
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Fill input field with automatic retry using interaction map fallback.
* Supports both CSS selectors and XPath (when selector starts with '//').
* XPath supports indexing: (//input[@type='text'])[2] selects the 2nd input.
* Uses CDP click + insertText for proper React compatibility.
*
* On failure, attempts to find alternative selectors from interaction map and retries.
*/
export async function fill(
browser: ChromeBrowser,
selector: string,
value: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
try {
// First attempt with provided selector
return await fillCore(browser, selector, value, options);
} catch (error: unknown) {
// Check if map file exists
const mapPath = join(process.cwd(), SELECTOR_RETRY_CONFIG.MAP_FOLDER, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
if (!existsSync(mapPath)) {
if (opts.verbose) {
logger.info('⚠️ No interaction map found for fallback. Rethrowing error.');
}
throw error;
}
if (opts.verbose) {
logger.info('🔄 Attempting to find alternative selectors from map...');
}
// Try to find alternative selectors
let fallbackResult: { selector: string; alternatives: string[] } | null = null;
try {
// If original selector looks like an ID, try querying by ID
if (selector.startsWith('#')) {
const id = selector.slice(1);
fallbackResult = findSelectorWithFallback(mapPath, { id });
}
// If selector contains text in XPath format, extract and search
else if (selector.includes('contains(text()')) {
const textMatch = selector.match(/contains\(text\(\),\s*['"](.+?)['"]\)/);
if (textMatch && textMatch[1]) {
fallbackResult = findSelectorWithFallback(mapPath, { text: textMatch[1] });
}
}
// For input fields, try to find by type
else if (selector.includes('input')) {
fallbackResult = findSelectorWithFallback(mapPath, { type: 'input' });
}
} catch (mapError: unknown) {
if (opts.verbose) {
const mapErrorMessage = mapError instanceof Error ? mapError.message : String(mapError);
logger.info(`⚠️ Map query failed: ${mapErrorMessage}`);
}
throw error; // Rethrow original error
}
if (!fallbackResult || fallbackResult.alternatives.length === 0) {
if (opts.verbose) {
logger.info('⚠️ No alternative selectors found in map.');
}
throw error; // Rethrow original error
}
// Try alternative selectors (limit to MAX_ATTEMPTS - 1, since we already tried once)
const maxRetries = Math.min(
fallbackResult.alternatives.length,
SELECTOR_RETRY_CONFIG.MAX_ATTEMPTS - 1
);
for (let i = 0; i < maxRetries; i++) {
const altSelector = fallbackResult.alternatives[i];
if (opts.verbose) {
logger.info(`🔄 Retry ${i + 1}/${maxRetries} with selector: ${altSelector}`);
}
try {
return await fillCore(browser, altSelector, value, options);
} catch (_retryError: unknown) {
if (i === maxRetries - 1) {
// Last retry failed, throw original error
if (opts.verbose) {
logger.info('❌ All retry attempts exhausted.');
}
throw error;
}
// Continue to next alternative
}
}
// Should not reach here, but throw original error as fallback
throw error;
}
}
/**
* Hover over element.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Uses CDP mouseMoved event for proper React compatibility.
*/
export async function hover(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Hovering: ${selector}`);
// Step 1: Find element and scroll into view
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
// Scroll element into view
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
// Get bounding box and calculate center point
const box = el.getBoundingClientRect();
return {
x: box.left + box.width / 2,
y: box.top + box.height / 2,
tag: el.tagName,
text: el.textContent?.substring(0, 50) || '',
visible: box.width > 0 && box.height > 0
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element not found or error occurred');
if (result.exceptionDetails) {
logger.error('Error:', result.exceptionDetails.exception?.description || result.exceptionDetails.text);
}
throw new Error(`Element not found: ${selector}`);
}
const { x, y, tag, text, visible } = result.result.value as { x: number; y: number; tag: string; text: string; visible: boolean };
if (opts.verbose) {
logger.info(`✓ Element found: <${tag.toLowerCase()}> "${text}"`);
logger.info(` Position: (${Math.round(x)}, ${Math.round(y)}), Visible: ${visible}`);
logger.info(`🖱️ Moving mouse to (${Math.round(x)}, ${Math.round(y)})`);
}
// Step 2: Dispatch CDP mouse move event
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
x,
y
});
if (opts.verbose) logger.info(`✅ Hover successful`);
await waitForActionComplete(browser, opts);
return {
success: true,
selector,
coordinates: { x: Math.round(x), y: Math.round(y) },
element: { tag, text }
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Hover failed: ${selector}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
await waitForActionComplete(browser, opts);
throw error;
}
}
/**
* Focus element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function focus(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Focusing: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.focus();
return true;
})()
`;
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Focus successful`);
await waitForActionComplete(browser, opts);
return { success: true, selector };
}
/**
* Blur element.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function blur(
browser: ChromeBrowser,
selector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Blurring: ${selector}`);
const script = `
(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.blur();
return true;
})()
`;
await browser.sendCommand('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Blur successful`);
await waitForActionComplete(browser, opts);
return { success: true, selector };
}
/**
* Drag and drop from one element to another.
* Uses CDP mouse events for proper React/framework compatibility.
*/
export async function dragAndDrop(
browser: ChromeBrowser,
sourceSelector: string,
targetSelector: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔍 Dragging ${sourceSelector} to ${targetSelector}`);
// Step 1: Get coordinates for both elements
const script = `
(function() {
const sourceSelector = ${JSON.stringify(sourceSelector)};
const targetSelector = ${JSON.stringify(targetSelector)};
${getFindElementScript()}
const source = findElement(sourceSelector);
const target = findElement(targetSelector);
if (!source) throw new Error('Source element not found: ' + sourceSelector);
if (!target) throw new Error('Target element not found: ' + targetSelector);
// Scroll both into view
source.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const sourceRect = source.getBoundingClientRect();
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
const targetRect = target.getBoundingClientRect();
return {
source: {
x: sourceRect.left + sourceRect.width / 2,
y: sourceRect.top + sourceRect.height / 2,
tag: source.tagName,
text: source.textContent?.substring(0, 30) || ''
},
target: {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
tag: target.tagName,
text: target.textContent?.substring(0, 30) || ''
}
};
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (!result.result || !result.result.value) {
logger.error('❌ Element(s) not found');
throw new Error('Could not find source or target element');
}
const { source, target } = result.result.value as {
source: { x: number; y: number; tag: string; text: string };
target: { x: number; y: number; tag: string; text: string };
};
if (opts.verbose) {
logger.info(`✓ Source: <${source.tag.toLowerCase()}> "${source.text}" at (${Math.round(source.x)}, ${Math.round(source.y)})`);
logger.info(`✓ Target: <${target.tag.toLowerCase()}> "${target.text}" at (${Math.round(target.x)}, ${Math.round(target.y)})`);
}
// Step 2: Perform CDP drag operation
if (opts.verbose) logger.info(`🖱️ Mouse down at source (${Math.round(source.x)}, ${Math.round(source.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
button: 'left',
clickCount: 1,
x: source.x,
y: source.y
});
// Small delay to simulate drag start
await sleep(TIMING.ACTION_DELAY_MEDIUM);
if (opts.verbose) logger.info(`🖱️ Dragging to target (${Math.round(target.x)}, ${Math.round(target.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseMoved',
button: 'left',
x: target.x,
y: target.y
});
// Small delay before release
await sleep(TIMING.ACTION_DELAY_MEDIUM);
if (opts.verbose) logger.info(`🖱️ Mouse up at target (${Math.round(target.x)}, ${Math.round(target.y)})`);
await browser.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button: 'left',
clickCount: 1,
x: target.x,
y: target.y
});
if (opts.verbose) logger.info(`✅ Drag and drop successful`);
await waitForActionComplete(browser, opts);
return {
success: true,
sourceSelector,
targetSelector,
source: { x: Math.round(source.x), y: Math.round(source.y) },
target: { x: Math.round(target.x), y: Math.round(target.y) }
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Drag and drop failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
await waitForActionComplete(browser, opts);
throw error;
}
}

View File

@@ -0,0 +1,217 @@
/**
* Navigation actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors } from './helpers';
import { logger } from '../../utils/logger';
import { TIMING } from '../../constants';
/**
* Navigate to URL.
*/
export async function navigate(
browser: ChromeBrowser,
url: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🧭 Navigating to: ${url}`);
try {
await browser.sendCommand('Page.navigate', { url });
await sleep(TIMING.ACTION_DELAY_NAVIGATION); // Wait for initial page load
if (opts.verbose) logger.info(`✓ Page loaded: ${url}`);
checkErrors(browser, opts.logLevel);
return { success: true, url };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Navigation failed: ${url}`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Wait for page load complete.
*/
export async function waitForLoad(
browser: ChromeBrowser,
timeout = 30000,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for page load (timeout: ${timeout}ms)...`);
const script = `
new Promise((resolve, reject) => {
const startTime = Date.now();
const checkReady = () => {
if (document.readyState === 'complete') {
resolve(true);
} else if (Date.now() - startTime > ${timeout}) {
reject(new Error('Timeout waiting for page load'));
} else {
setTimeout(checkReady, ${TIMING.POLLING_INTERVAL_FAST});
}
};
checkReady();
})
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Page load complete`);
return { success: true, state: 'complete' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Page load failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Reload page.
*/
export async function reload(
browser: ChromeBrowser,
hard = false,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🔄 Reloading page (hard: ${hard})...`);
try {
await browser.sendCommand('Page.reload', { ignoreCache: hard });
if (opts.verbose) logger.info(`✅ Page reloaded`);
return { success: true, hardReload: hard };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Reload failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Navigate back in history.
*/
export async function goBack(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`◀️ Navigating back...`);
try {
const history = await browser.sendCommand<{
currentIndex: number;
entries: Array<{ id: number; url: string; title: string }>;
}>('Page.getNavigationHistory');
const currentIndex = history.currentIndex || 0;
if (currentIndex > 0) {
const previousEntry = history.entries[currentIndex - 1];
await browser.sendCommand('Page.navigateToHistoryEntry', {
entryId: previousEntry.id
});
if (opts.verbose) logger.info(`✅ Navigated back to: ${previousEntry.url}`);
return { success: true, url: previousEntry.url };
}
if (opts.verbose) logger.info(`⚠️ No previous page in history`);
return { success: false, error: 'No previous page in history' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Go back failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Navigate forward in history.
*/
export async function goForward(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`▶️ Navigating forward...`);
try {
const history = await browser.sendCommand<{
currentIndex: number;
entries: Array<{ id: number; url: string; title: string }>;
}>('Page.getNavigationHistory');
const currentIndex = history.currentIndex || 0;
const totalEntries = history.entries?.length || 0;
if (currentIndex < totalEntries - 1) {
const nextEntry = history.entries[currentIndex + 1];
await browser.sendCommand('Page.navigateToHistoryEntry', {
entryId: nextEntry.id
});
if (opts.verbose) logger.info(`✅ Navigated forward to: ${nextEntry.url}`);
return { success: true, url: nextEntry.url };
}
if (opts.verbose) logger.info(`⚠️ No next page in history`);
return { success: false, error: 'No next page in history' };
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Go forward failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,194 @@
/**
* Network interception and mocking actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
/**
* Set up network request interception.
*/
export async function enableRequestInterception(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Enabling network request interception...`);
try {
await browser.sendCommand('Fetch.enable', {
patterns: [{ urlPattern: '*' }]
});
if (opts.verbose) logger.info(`✅ Request interception enabled`);
return {
success: true,
note: 'Request interception enabled. Use interceptRequest() to handle requests.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Enable request interception failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Disable network request interception.
*/
export async function disableRequestInterception(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Disabling network request interception...`);
try {
await browser.sendCommand('Fetch.disable');
if (opts.verbose) logger.info(`✅ Request interception disabled`);
return {
success: true
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Disable request interception failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Mock a network request response.
*/
export async function mockRequest(
browser: ChromeBrowser,
urlPattern: string,
responseBody: string,
statusCode: number = 200,
headers?: Record<string, string>,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Mocking request: ${urlPattern} -> ${statusCode}`);
try {
// This is a simplified version - full implementation requires event handling
await browser.sendCommand('Fetch.enable', {
patterns: [{ urlPattern }]
});
if (opts.verbose) logger.info(`✅ Mock configured for: ${urlPattern}`);
return {
success: true,
urlPattern,
statusCode,
note: 'Mock configured. Use Fetch.continueRequest or Fetch.fulfillRequest in event handler.'
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Mock request failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Block network requests matching pattern.
*/
export async function blockRequest(
browser: ChromeBrowser,
urlPattern: string,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🚫 Blocking requests matching: ${urlPattern}`);
try {
await browser.sendCommand('Network.enable');
await browser.sendCommand('Network.setBlockedURLs', {
urls: [urlPattern]
});
if (opts.verbose) logger.info(`✅ Requests blocked: ${urlPattern}`);
return {
success: true,
urlPattern,
blocked: true
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Block request failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}
/**
* Unblock all network requests.
*/
export async function unblockRequests(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`🌐 Unblocking all requests...`);
try {
await browser.sendCommand('Network.setBlockedURLs', {
urls: []
});
if (opts.verbose) logger.info(`✅ All requests unblocked`);
return {
success: true,
blocked: false
};
} catch (error: unknown) {
if (opts.verbose) {
logger.error(`❌ Unblock requests failed`);
if (error instanceof Error) {
logger.error(` Error: ${error.message}`);
} else {
logger.error(` Error: ${String(error)}`);
}
}
throw error;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Scroll actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
/**
* Scroll page or element.
* Supports both CSS selectors and XPath (when selector starts with '//').
* Note: x and y are both optional - you can scroll on just one axis if needed.
*/
export async function scroll(
browser: ChromeBrowser,
options?: { x?: number; y?: number; selector?: string } & ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
const x = options?.x ?? 0;
const y = options?.y ?? 0;
const selector = options?.selector;
if (opts.verbose) logger.info(`📜 Scrolling to (${x}, ${y})${selector ? ` on ${selector}` : ''}`);
const script = selector
? `
(function() {
const selector = ${JSON.stringify(selector)};
const x = ${JSON.stringify(x)};
const y = ${JSON.stringify(y)};
${getFindElementScript()}
const el = findElement(selector);
if (!el) throw new Error('Element not found: ' + selector);
el.scrollTo(x, y);
return { x: el.scrollLeft, y: el.scrollTop };
})()
`
: `
(function() {
const x = ${JSON.stringify(x)};
const y = ${JSON.stringify(y)};
window.scrollTo(x, y);
return { x: window.scrollX, y: window.scrollY };
})()
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Scrolled successfully`);
checkErrors(browser, opts.logLevel);
return {
success: true,
position: result.result?.value
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Scroll failed`);
logger.error(` Error: ${errorMessage}`);
}
checkErrors(browser, opts.logLevel);
throw error;
}
}

View File

@@ -0,0 +1,164 @@
/**
* Tab management actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { ActionResult, ActionOptions, mergeOptions } from './helpers';
import { logger } from '../../utils/logger';
import { CDP } from '../../constants';
// Target interface (from CDP)
interface Target {
id: string;
type: string;
url: string;
title: string;
webSocketDebuggerUrl?: string;
}
/**
* Create new tab.
*/
export async function newTab(
browser: ChromeBrowser,
url = 'about:blank',
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📑 Opening new tab: ${url}`);
const result = await browser.sendCommand('Target.createTarget', { url });
if (opts.verbose) logger.info(`✅ New tab created`);
return {
success: true,
targetId: result.targetId,
url
};
}
/**
* List all tabs.
*/
export async function listTabs(
browser: ChromeBrowser,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`📑 Listing all tabs...`);
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets
.filter((t: Target) => t.type === 'page')
.map((t: Target, index: number) => ({
index,
targetId: t.id,
url: t.url,
title: t.title
}));
if (opts.verbose) logger.info(`✅ Found ${pageTabs.length} tab(s)`);
return {
success: true,
tabs: pageTabs,
count: pageTabs.length
};
}
/**
* Switch to tab.
*/
export async function switchTab(
browser: ChromeBrowser,
targetId?: string,
index?: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (targetId) {
logger.info(`📑 Switching to tab: ${targetId}`);
} else if (index !== undefined) {
logger.info(`📑 Switching to tab index: ${index}`);
}
}
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets.filter((t: Target) => t.type === 'page');
let target: Target | undefined = undefined;
if (targetId) {
target = pageTabs.find((t: Target) => t.id === targetId);
} else if (index !== undefined) {
target = pageTabs[index];
}
if (!target) {
if (opts.verbose) logger.info(`❌ Target not found`);
return { success: false, error: 'Target not found' };
}
await browser.sendCommand('Target.activateTarget', { targetId: target.id });
if (opts.verbose) logger.info(`✅ Switched to tab: ${target.title}`);
return {
success: true,
targetId: target.id,
url: target.url,
title: target.title
};
}
/**
* Close tab.
*/
export async function closeTab(
browser: ChromeBrowser,
targetId?: string,
index?: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) {
if (targetId) {
logger.info(`📑 Closing tab: ${targetId}`);
} else if (index !== undefined) {
logger.info(`📑 Closing tab index: ${index}`);
}
}
const debugPort = browser.debugPort;
const response = await fetch(`http://${CDP.LOCALHOST}:${debugPort}/json`);
const targets = await response.json() as Target[];
const pageTabs = targets.filter((t: Target) => t.type === 'page');
let target: Target | undefined = undefined;
if (targetId) {
target = pageTabs.find((t: Target) => t.id === targetId);
} else if (index !== undefined) {
target = pageTabs[index];
}
if (!target) {
if (opts.verbose) logger.info(`❌ Target not found`);
return { success: false, error: 'Target not found' };
}
await browser.sendCommand('Target.closeTarget', { targetId: target.id });
if (opts.verbose) logger.info(`✅ Closed tab: ${target.title}`);
return {
success: true,
targetId: target.id,
message: `Closed tab: ${target.title}`
};
}

View File

@@ -0,0 +1,248 @@
/**
* Verification utilities for browser actions
*/
import { ChromeBrowser } from '../browser';
import { TIMING as GLOBAL_TIMING } from '../../constants';
// Timing constants for verification operations
const TIMING = {
DEFAULT_VERIFY_TIMEOUT: GLOBAL_TIMING.ACTION_DELAY_NAVIGATION,
DOM_CHANGE_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
NAVIGATION_CHECK_DELAY: GLOBAL_TIMING.NETWORK_IDLE_TIMEOUT,
MIN_DOM_CHANGE_THRESHOLD: 10, // Minimum change in DOM size to consider significant
} as const;
export interface VerifyOptions {
checkDOMChange?: boolean; // Check for DOM mutations
checkNavigation?: boolean; // Check for page navigation
timeout?: number; // Wait timeout in ms (default: 1000)
}
export interface VerificationResult {
success: boolean;
reason?: string;
domChanged?: boolean;
navigated?: boolean;
}
/**
* Check if an element exists in the DOM
*/
export async function elementExists(
browser: ChromeBrowser,
selector: string
): Promise<boolean> {
try {
const result = await browser.sendCommand('Runtime.evaluate', {
expression: `
(function() {
const selector = ${JSON.stringify(selector)};
let element = null;
// Try XPath first
if (selector.startsWith('//')) {
const xpathResult = document.evaluate(
selector,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
element = xpathResult.singleNodeValue;
}
// Try CSS selector
else {
element = document.querySelector(selector);
}
return element !== null;
})()
`,
returnByValue: true
}) as { result?: { value?: boolean } };
return result?.result?.value === true;
} catch (_error: unknown) {
return false;
}
}
/**
* Wait for DOM changes (using MutationObserver simulation)
*/
export async function waitForDOMChange(
browser: ChromeBrowser,
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
): Promise<boolean> {
try {
// Get initial DOM snapshot
const initialSnapshot = await browser.sendCommand('Runtime.evaluate', {
expression: 'document.body.innerHTML.length',
returnByValue: true
}) as { result?: { value?: number } };
const initialLength = initialSnapshot?.result?.value || 0;
// Wait a bit
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.DOM_CHANGE_CHECK_DELAY)));
// Get new DOM snapshot
const finalSnapshot = await browser.sendCommand('Runtime.evaluate', {
expression: 'document.body.innerHTML.length',
returnByValue: true
}) as { result?: { value?: number } };
const finalLength = finalSnapshot?.result?.value || 0;
// Check if DOM changed significantly
return Math.abs(finalLength - initialLength) > TIMING.MIN_DOM_CHANGE_THRESHOLD;
} catch (_error: unknown) {
return false;
}
}
/**
* Check for page navigation
*/
export async function checkNavigation(
browser: ChromeBrowser,
initialURL: string,
timeout: number = TIMING.DEFAULT_VERIFY_TIMEOUT
): Promise<boolean> {
try {
// Wait a bit for navigation to complete
await new Promise(resolve => setTimeout(resolve, Math.min(timeout, TIMING.NAVIGATION_CHECK_DELAY)));
// Get current URL
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
targetId: (browser as unknown as { targetId: string }).targetId
}) as { targetInfo: { url: string } };
const currentURL = urlResult.targetInfo.url;
return currentURL !== initialURL;
} catch (_error: unknown) {
return false;
}
}
/**
* Verify that an action was successful
*/
export async function verifyAction(
browser: ChromeBrowser,
options: VerifyOptions = {}
): Promise<VerificationResult> {
const {
checkDOMChange = true,
checkNavigation: shouldCheckNavigation = true,
timeout = TIMING.DEFAULT_VERIFY_TIMEOUT
} = options;
const result: VerificationResult = {
success: false
};
try {
// Get initial URL
let initialURL = '';
if (shouldCheckNavigation) {
const urlResult = await browser.sendCommand('Target.getTargetInfo', {
targetId: (browser as unknown as { targetId: string }).targetId
}) as { targetInfo: { url: string } };
initialURL = urlResult.targetInfo.url;
}
// Check for DOM changes
if (checkDOMChange) {
result.domChanged = await waitForDOMChange(browser, timeout);
}
// Check for navigation
if (shouldCheckNavigation) {
result.navigated = await checkNavigation(browser, initialURL, timeout);
}
// Success if either DOM changed or navigation occurred
result.success = !!(result.domChanged || result.navigated);
if (!result.success) {
result.reason = 'No DOM changes or navigation detected';
}
return result;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
reason: `Verification failed: ${errorMessage}`
};
}
}
/**
* Verify element interactivity before action
*/
export async function verifyElementInteractive(
browser: ChromeBrowser,
selector: string
): Promise<{ interactive: boolean; reason?: string }> {
try {
const result = await browser.sendCommand('Runtime.evaluate', {
expression: `
(function() {
const selector = ${JSON.stringify(selector)};
let element = null;
// Try XPath first
if (selector.startsWith('//')) {
const xpathResult = document.evaluate(
selector,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
element = xpathResult.singleNodeValue;
}
// Try CSS selector
else {
element = document.querySelector(selector);
}
if (!element) {
return { interactive: false, reason: 'Element not found' };
}
// Check visibility
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return { interactive: false, reason: 'Element not visible' };
}
// Check if disabled
if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
return { interactive: false, reason: 'Element is disabled' };
}
// Check if element is in viewport
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return { interactive: false, reason: 'Element has no size' };
}
return { interactive: true };
})()
`,
returnByValue: true
}) as { result?: { value?: { interactive: boolean; reason?: string } } };
const value = result?.result?.value;
if (value && typeof value === 'object') {
return value;
}
return { interactive: false, reason: 'Verification failed' };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { interactive: false, reason: errorMessage };
}
}

View File

@@ -0,0 +1,217 @@
/**
* Wait actions for Browser Pilot.
*/
import { ChromeBrowser } from '../browser';
import { getFindElementScript } from '../utils';
import { ActionResult, ActionOptions, mergeOptions, sleep, checkErrors, RuntimeEvaluateResult } from './helpers';
import { logger } from '../../utils/logger';
import { TIMING, CDP } from '../../constants';
/**
* Wait for specified milliseconds.
*/
export async function waitMilliseconds(
browser: ChromeBrowser,
ms: number,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for ${ms}ms...`);
await sleep(ms);
if (opts.verbose) logger.info(`✅ Wait complete`);
return { success: true, waitedMs: ms };
}
/**
* Wait for element to appear.
* Supports both CSS selectors and XPath (when selector starts with '//').
*/
export async function waitFor(
browser: ChromeBrowser,
selector: string,
timeout = TIMING.WAIT_FOR_NAVIGATION,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for: ${selector}`);
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const script = `(function() {
const selector = ${JSON.stringify(selector)};
${getFindElementScript()}
return findElement(selector) !== null;
})()`;
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
if (result.result?.value) {
if (opts.verbose) logger.info(`✅ Element appeared: ${selector}`);
checkErrors(browser, opts.logLevel);
return { success: true, selector };
}
await sleep(TIMING.POLLING_INTERVAL_FAST);
}
if (opts.verbose) logger.info(`❌ Timeout waiting for: ${selector}`);
throw new Error(`Timeout waiting for: ${selector}`);
}
/**
* Wait for network to be idle.
*/
export async function waitForNetworkIdle(
browser: ChromeBrowser,
timeout: number = TIMING.WAIT_FOR_ELEMENT,
_maxInflight = 0,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for network idle (timeout: ${timeout}ms)...`);
await browser.sendCommand('Network.enable');
const script = `
new Promise((resolve) => {
const waitForNavigationComplete = () => {
if (performance.timing.loadEventEnd > 0) {
setTimeout(() => resolve(true), ${timeout});
} else {
setTimeout(waitForNavigationComplete, ${TIMING.POLLING_INTERVAL_FAST});
}
};
waitForNavigationComplete();
})
`;
try {
await browser.sendCommand('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
if (opts.verbose) logger.info(`✅ Network idle`);
return { success: true, state: 'network_idle' };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ Network idle wait failed`);
logger.error(` Error: ${errorMessage}`);
}
throw error;
}
}
/**
* Wait for DOM to stabilize (no mutations for specified time).
* Uses MutationObserver to detect when DOM changes stop.
*/
export async function waitForDomStable(
browser: ChromeBrowser,
stableTime: number = TIMING.NETWORK_IDLE_TIMEOUT,
timeout: number = CDP.EVALUATION_TIMEOUT,
options?: ActionOptions
): Promise<ActionResult> {
const opts = mergeOptions(options);
if (opts.verbose) logger.info(`⏳ Waiting for DOM to stabilize (stable: ${stableTime}ms, timeout: ${timeout}ms)...`);
const script = `
new Promise((resolve, reject) => {
const stableTime = ${stableTime};
const timeout = ${timeout};
let lastMutationTime = Date.now();
let stabilityTimer = null;
let timeoutTimer = null;
// Timeout handler
timeoutTimer = setTimeout(() => {
observer.disconnect();
resolve({ stable: false, reason: 'timeout' });
}, timeout);
// Check if stable
const checkStability = () => {
const timeSinceLastMutation = Date.now() - lastMutationTime;
if (timeSinceLastMutation >= stableTime) {
clearTimeout(timeoutTimer);
observer.disconnect();
resolve({ stable: true, waitedMs: Date.now() - startTime });
}
};
const startTime = Date.now();
// MutationObserver to detect DOM changes
const observer = new MutationObserver((mutations) => {
// Filter out trivial mutations (like class changes on same element)
const significantMutations = mutations.filter(m => {
// Ignore attribute changes unless they're critical
if (m.type === 'attributes' && !['style', 'class'].includes(m.attributeName)) {
return false;
}
// Count childList and subtree changes as significant
return m.type === 'childList' || m.addedNodes.length > 0 || m.removedNodes.length > 0;
});
if (significantMutations.length > 0) {
lastMutationTime = Date.now();
// Reset stability timer
if (stabilityTimer) clearTimeout(stabilityTimer);
stabilityTimer = setTimeout(checkStability, stableTime);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
// Initial stability check (in case DOM is already stable)
stabilityTimer = setTimeout(checkStability, stableTime);
})
`;
try {
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
awaitPromise: true,
returnByValue: true
});
const data = result.result?.value as { stable: boolean; waitedMs?: number; reason?: string };
if (data.stable) {
if (opts.verbose) logger.info(`✅ DOM stabilized (waited: ${data.waitedMs}ms)`);
return { success: true, stable: true, waitedMs: data.waitedMs };
} else {
if (opts.verbose) logger.warn(`⚠️ DOM stabilization timeout (reason: ${data.reason})`);
return { success: true, stable: false, reason: data.reason };
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.verbose) {
logger.error(`❌ DOM stability wait failed`);
logger.error(` Error: ${errorMessage}`);
}
throw error;
}
}

View File

@@ -0,0 +1,580 @@
/**
* Chrome browser launcher and connection manager.
*/
import { spawn, ChildProcess } from 'child_process';
import { homedir, platform } from 'os';
import { existsSync } from 'fs';
import { join } from 'path';
import { CDPClient } from './client';
import {
getProjectConfig as _getProjectConfig,
getProjectPort,
updateProjectLastUsed,
cleanupProjectIfNeeded,
isPortAvailable
} from './config';
import { logger } from '../utils/logger';
import { TIMING, CDP } from '../constants';
interface Target {
type: string;
webSocketDebuggerUrl: string;
id?: string;
title?: string;
url?: string;
[key: string]: unknown;
}
// CDP Event Supporting Interfaces
export interface StackTrace {
callFrames?: Array<{
url?: string;
lineNumber?: number;
columnNumber?: number;
functionName?: string;
}>;
}
export interface RemoteObject {
type?: string;
value?: unknown;
description?: string;
[key: string]: unknown;
}
// Page Navigation Interfaces
export interface PageNavigatedWithinDocumentPayload {
frameId: string;
url: string;
navigationType: 'fragment' | 'historyApi' | 'other';
}
// Console Message Interfaces
export interface ConsoleMessage {
level: string;
text: string;
timestamp: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
export interface FormattedConsoleMessage {
level: string;
text: string;
timestamp: string; // ISO string format
url?: string;
lineNumber?: number;
}
// Network Error Interfaces
export interface NetworkError {
url: string;
errorText: string;
timestamp: number;
statusCode?: number;
requestId: string;
}
export interface NetworkFailedPayload {
requestId: string;
timestamp: number;
type?: string;
errorText: string;
canceled?: boolean;
}
export interface NetworkResponsePayload {
requestId: string;
response: {
url: string;
status: number;
statusText: string;
};
}
// Network Request Tracking Interface
export interface NetworkRequestPayload {
requestId: string;
request: {
url: string;
method?: string;
headers?: Record<string, string>;
};
timestamp: number;
type?: string;
}
// CDP Event Handler Type
type CDPEventHandler =
| ((params: LogEntryAddedPayload) => void)
| ((params: ConsoleAPICalledPayload) => void)
| ((params: ExceptionThrownPayload) => void)
| ((params: NetworkRequestPayload) => void)
| ((params: NetworkFailedPayload) => void)
| ((params: NetworkResponsePayload) => void);
// CDP Event Payload Interfaces
interface LogEntry {
level?: 'verbose' | 'info' | 'warning' | 'error';
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
interface LogEntryAddedPayload {
entry: LogEntry;
}
interface ConsoleAPICalledPayload {
type?: string;
args?: RemoteObject[];
timestamp?: number;
stackTrace?: StackTrace;
}
interface ExceptionDetails {
exception?: {
description?: string;
};
text?: string;
timestamp?: number;
url?: string;
lineNumber?: number;
stackTrace?: StackTrace;
}
interface ExceptionThrownPayload {
exceptionDetails: ExceptionDetails;
}
export class ChromeBrowser {
private readonly headless: boolean;
public debugPort: number | null = null;
private chromeProcess: ChildProcess | null = null;
public client: CDPClient | null = null;
private consoleMessages: ConsoleMessage[] = [];
private networkErrors: NetworkError[] = [];
private readonly MAX_CONSOLE_MESSAGES = TIMING.MAP_CACHE_TTL / 600; // 1000 messages (10min / 600ms)
private readonly MAX_NETWORK_ERRORS = TIMING.MAP_CACHE_TTL / 600; // 1000 errors
private pendingRequests: Map<string, { url: string; timestamp: number; processed: boolean }> = new Map();
private readonly REQUEST_TIMEOUT = TIMING.DAEMON_IDLE_TIMEOUT / 30; // ~60 seconds
private cleanupInterval: NodeJS.Timeout | null = null;
private eventListeners: Map<string, CDPEventHandler> = new Map();
constructor(headless = false) {
this.headless = headless;
// Debug port will be loaded from shared config in launch/connect methods
}
/**
* Add console message with size limit to prevent memory issues.
*/
private addConsoleMessage(message: ConsoleMessage): void {
this.consoleMessages.push(message);
// Keep only the most recent messages
if (this.consoleMessages.length > this.MAX_CONSOLE_MESSAGES) {
this.consoleMessages = this.consoleMessages.slice(-this.MAX_CONSOLE_MESSAGES);
}
}
/**
* Add network error with size limit to prevent memory issues.
*/
private addNetworkError(error: NetworkError): void {
this.networkErrors.push(error);
// Keep only the most recent errors
if (this.networkErrors.length > this.MAX_NETWORK_ERRORS) {
this.networkErrors = this.networkErrors.slice(-this.MAX_NETWORK_ERRORS);
}
}
/**
* Clean up stale pending requests to prevent memory leak.
*/
private cleanupStaleRequests(): void {
const now = Date.now();
for (const [requestId, request] of this.pendingRequests.entries()) {
if (request.processed || (now - request.timestamp > this.REQUEST_TIMEOUT)) {
this.pendingRequests.delete(requestId);
}
}
}
/**
* Find Chrome executable path.
*/
private getChromePath(): string {
const system = platform();
let paths: string[] = [];
if (system === 'win32') {
paths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
join(homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe')
];
} else if (system === 'darwin') {
paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
];
} else {
paths = [
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium'
];
}
for (const path of paths) {
if (existsSync(path)) {
return path;
}
}
throw new Error('Chrome not found. Please install Google Chrome.');
}
/**
* Connect to already running Chrome instance.
*/
async connect(): Promise<void> {
// Get project port from shared config
const port = await getProjectPort();
// Check if the port is in use (browser running)
const portAvailable = await isPortAvailable(port);
if (!portAvailable) {
// Port is in use, browser is running
this.debugPort = port;
logger.info(`Connecting to existing Chrome on port ${this.debugPort}...`);
await this.connectToPage();
updateProjectLastUsed();
return;
}
// No running browser found
throw new Error(`No running browser found on port ${port}`);
}
/**
* Launch Chrome in debugging mode.
* @param initialUrl - Optional initial URL to open (defaults to about:blank)
*/
async launch(initialUrl?: string): Promise<void> {
// Get project port from shared config (auto-creates if not exists)
this.debugPort = await getProjectPort();
const chromePath = this.getChromePath();
const args = [
`--remote-debugging-port=${this.debugPort}`,
'--remote-allow-origins=*',
'--no-first-run',
'--no-default-browser-check',
`--user-data-dir=${join(homedir(), `.cdp_browser_profile_${this.debugPort}`)}`
];
if (this.headless) {
args.push('--headless=new', '--disable-gpu');
}
// Add initial URL or default to blank page
if (initialUrl) {
args.push(initialUrl);
logger.info(`Launching Chrome with initial URL: ${initialUrl}`);
} else {
args.push('about:blank');
}
logger.info(`Launching Chrome on port ${this.debugPort} (headless: ${this.headless})...`);
this.chromeProcess = spawn(chromePath, args, {
stdio: 'ignore',
detached: true
});
// Detach the process so it continues running when Node exits
this.chromeProcess.unref();
// Update last used timestamp
updateProjectLastUsed();
// Wait for Chrome to be ready by polling the JSON endpoint
let attempts = 0;
const maxAttempts = 20; // 10 seconds (20 * 500ms)
let connected = false;
while (attempts < maxAttempts) {
try {
const response = await fetch(`http://${CDP.LOCALHOST}:${this.debugPort}/json/version`);
if (response.ok) {
connected = true;
break;
}
} catch (_error) {
// Connection may be refused while browser is starting up
}
attempts++;
await this.sleep(TIMING.NETWORK_IDLE_TIMEOUT);
}
if (!connected) {
throw new Error(`Failed to connect to Chrome within the timeout period (${maxAttempts * TIMING.NETWORK_IDLE_TIMEOUT / TIMING.ACTION_DELAY_NAVIGATION} seconds).`);
}
// Connect to page target
await this.connectToPage();
}
/**
* Connect to a Chrome page target.
*/
private async connectToPage(): Promise<void> {
try {
// Get list of targets
const url = `http://${CDP.LOCALHOST}:${this.debugPort}/json`;
const response = await fetch(url);
const targets = await response.json() as Target[];
// Find or create a page target
let pageTarget = targets.find(t => t.type === 'page');
if (!pageTarget) {
// Create new target
const newUrl = `http://${CDP.LOCALHOST}:${this.debugPort}/json/new`;
const newResponse = await fetch(newUrl);
pageTarget = await newResponse.json() as Target;
}
const wsUrl = pageTarget.webSocketDebuggerUrl;
logger.info(`Connecting to: ${wsUrl}`);
this.client = new CDPClient(wsUrl);
await this.client.connect();
logger.info('Connected to Chrome DevTools Protocol');
// Enable Log domain to receive console messages
await this.client.sendCommand('Log.enable');
await this.client.sendCommand('Runtime.enable');
// Enable Network domain to track network errors
await this.client.sendCommand('Network.enable');
// Set up event listeners with references for cleanup
const logEntryHandler = (params: LogEntryAddedPayload) => {
const entry = params.entry;
this.addConsoleMessage({
level: entry.level || 'log',
text: entry.text || '',
timestamp: entry.timestamp || Date.now(),
url: entry.url,
lineNumber: entry.lineNumber,
stackTrace: entry.stackTrace
});
};
const consoleApiHandler = (params: ConsoleAPICalledPayload) => {
const args = params.args || [];
const text = args.map((arg: RemoteObject) => arg.value || arg.description || '').join(' ');
this.addConsoleMessage({
level: params.type || 'log',
text: text,
timestamp: params.timestamp || Date.now(),
url: params.stackTrace?.callFrames?.[0]?.url,
lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber
});
};
const exceptionHandler = (params: ExceptionThrownPayload) => {
const exception = params.exceptionDetails;
const text = exception.exception?.description || exception.text || 'Unknown error';
this.addConsoleMessage({
level: 'error',
text: text,
timestamp: exception.timestamp || Date.now(),
url: exception.url,
lineNumber: exception.lineNumber,
stackTrace: exception.stackTrace
});
};
const requestWillBeSentHandler = (params: NetworkRequestPayload) => {
this.pendingRequests.set(params.requestId, {
url: params.request.url,
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
processed: false
});
};
const loadingFailedHandler = (params: NetworkFailedPayload) => {
const request = this.pendingRequests.get(params.requestId);
if (request && !request.processed && !params.canceled) {
this.addNetworkError({
url: request.url,
errorText: params.errorText,
timestamp: params.timestamp !== undefined ? params.timestamp * TIMING.ACTION_DELAY_NAVIGATION : Date.now(),
requestId: params.requestId
});
request.processed = true;
}
};
const responseReceivedHandler = (params: NetworkResponsePayload) => {
const { requestId, response } = params;
const request = this.pendingRequests.get(requestId);
if (request && !request.processed && response.status >= 400) {
this.addNetworkError({
url: response.url,
errorText: `HTTP ${response.status} ${response.statusText}`,
timestamp: Date.now(),
statusCode: response.status,
requestId: requestId
});
request.processed = true;
}
};
// Register event listeners
this.client.on('Log.entryAdded', logEntryHandler);
this.eventListeners.set('Log.entryAdded', logEntryHandler);
this.client.on('Runtime.consoleAPICalled', consoleApiHandler);
this.eventListeners.set('Runtime.consoleAPICalled', consoleApiHandler);
this.client.on('Runtime.exceptionThrown', exceptionHandler);
this.eventListeners.set('Runtime.exceptionThrown', exceptionHandler);
this.client.on('Network.requestWillBeSent', requestWillBeSentHandler);
this.eventListeners.set('Network.requestWillBeSent', requestWillBeSentHandler);
this.client.on('Network.loadingFailed', loadingFailedHandler);
this.eventListeners.set('Network.loadingFailed', loadingFailedHandler);
this.client.on('Network.responseReceived', responseReceivedHandler);
this.eventListeners.set('Network.responseReceived', responseReceivedHandler);
// Start periodic cleanup of stale requests
this.cleanupInterval = setInterval(() => this.cleanupStaleRequests(), TIMING.POLLING_INTERVAL_SLOW * 10);
} catch (error) {
throw new Error(`Failed to connect to Chrome: ${error}`);
}
}
/**
* Send CDP command.
*/
async sendCommand<T = Record<string, unknown>>(
method: string,
params?: unknown
): Promise<T> {
if (!this.client) {
throw new Error('Not connected to Chrome');
}
return this.client.sendCommand<T>(method, params);
}
/**
* Get collected console messages.
*/
getConsoleMessages(): ConsoleMessage[] {
return [...this.consoleMessages];
}
/**
* Clear console messages buffer.
*/
clearConsoleMessages(): void {
this.consoleMessages = [];
}
/**
* Get collected network errors.
*/
getNetworkErrors(): NetworkError[] {
return [...this.networkErrors];
}
/**
* Clear network errors buffer.
*/
clearNetworkErrors(): void {
this.networkErrors = [];
}
/**
* Close browser and cleanup.
*/
async close(): Promise<void> {
logger.info('Closing browser...');
// Stop cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Remove all event listeners
for (const [event, handler] of this.eventListeners.entries()) {
this.client?.off(event, handler);
}
this.eventListeners.clear();
if (this.client) {
try {
// Send Browser.close command to gracefully close the browser
await this.client.sendCommand('Browser.close');
logger.info('Browser closed via CDP command');
} catch (_error) {
logger.info('Could not close browser via CDP, it may already be closed');
}
// Close WebSocket connection
this.client.close();
}
// Force kill Chrome process if it's still running
if (this.chromeProcess) {
try {
// Check if process is still alive
if (!this.chromeProcess.killed) {
this.chromeProcess.kill('SIGTERM');
logger.info('Chrome process terminated (SIGTERM)');
// Wait a bit for graceful shutdown
await this.sleep(1000);
// Force kill if still alive
if (!this.chromeProcess.killed) {
this.chromeProcess.kill('SIGKILL');
logger.info('Chrome process force-killed (SIGKILL)');
}
}
} catch (error) {
logger.warn(`Failed to kill Chrome process: ${error instanceof Error ? error.message : String(error)}`);
}
this.chromeProcess = null;
}
// Clear pending requests
this.pendingRequests.clear();
// Clean up project config if autoCleanup is enabled
cleanupProjectIfNeeded();
}
/**
* Sleep for specified milliseconds.
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,144 @@
/**
* CDP WebSocket Client for Chrome DevTools Protocol communication.
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
export interface CDPMessage {
id: number;
method: string;
params?: unknown;
}
export interface CDPResponse {
id: number;
result?: unknown;
error?: {
code: number;
message: string;
};
}
export interface CDPEvent {
method: string;
params?: unknown;
}
export class CDPClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 0;
private readonly wsUrl: string;
constructor(wsUrl: string) {
super();
this.wsUrl = wsUrl;
}
/**
* Connect to Chrome via WebSocket.
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.on('open', () => {
// Set up global message handler for CDP events
if (this.ws) {
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message = JSON.parse(data.toString());
// CDP events don't have 'id' field, only 'method' and 'params'
if (!message.id && message.method) {
this.emit('event', message as CDPEvent);
this.emit(message.method, message.params);
}
} catch (_error) {
// Ignore parse errors
}
});
}
resolve();
});
this.ws.on('error', (error) => {
reject(error);
});
});
}
/**
* Send CDP command and wait for response.
*/
async sendCommand<T = Record<string, unknown>>(
method: string,
params?: unknown
): Promise<T> {
if (!this.ws) {
throw new Error('Not connected to Chrome');
}
this.messageId++;
const message: CDPMessage = {
id: this.messageId,
method,
params: params || {}
};
return new Promise((resolve, reject) => {
const currentMessageId = this.messageId;
const cleanup = () => {
this.ws?.removeListener('message', messageHandler);
};
const messageHandler = (data: WebSocket.Data) => {
try {
const response: CDPResponse = JSON.parse(data.toString());
if (response.id === currentMessageId) {
cleanup();
if (response.error) {
reject(new Error(`CDP Error: ${JSON.stringify(response.error)}`));
} else {
resolve((response.result || {}) as T);
}
}
} catch (_error) {
// Ignore parse errors for other messages
}
};
if (!this.ws) {
reject(new Error('WebSocket connection lost'));
return;
}
this.ws.on('message', messageHandler);
try {
this.ws.send(JSON.stringify(message));
} catch (error) {
cleanup();
reject(error);
}
});
}
/**
* Close WebSocket connection.
*/
close(): void {
if (this.ws) {
try {
this.ws.close();
} catch (_error) {
// Ignore close errors
}
this.ws = null;
}
}
}

View File

@@ -0,0 +1,329 @@
/**
* Configuration management for browser debugging port and state.
* Uses a shared config file in the plugin folder for multi-project support.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, basename } from 'path';
import { createServer } from 'net';
import { findProjectRoot } from './utils';
import { CDP, FS } from '../constants';
import { logger } from '../utils/logger';
export interface ProjectConfig {
rootPath: string;
port: number;
outputDir: string;
lastUsed: string | null;
autoCleanup: boolean;
autoRestore?: boolean; // Auto-restore last visited URL (default: true)
}
export interface SharedBrowserPilotConfig {
projects: {
[projectName: string]: ProjectConfig;
};
}
/**
* Get local timestamp string (same format as logger)
* Format: YYYY-MM-DD HH:MM:SS.mmm
*/
function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
/**
* Get shared config file path in plugin skills folder
* Uses hardcoded home directory path for reliability
*/
function getSharedConfigPath(): string {
const { homedir } = require('os');
const homeDir = homedir();
return join(
homeDir,
'.claude',
'plugins',
'marketplaces',
'dev-gom-plugins',
'plugins',
'browser-pilot',
'skills',
'browser-pilot-config.json'
);
}
/**
* Get project name from root folder name
*/
function getProjectName(projectRoot: string): string {
return basename(projectRoot);
}
/**
* Get output directory for the current project
* Creates .browser-pilot folder in project root
*/
export function getOutputDir(): string {
const projectRoot = findProjectRoot();
const outputDir = join(projectRoot, FS.OUTPUT_DIR);
// Ensure .browser-pilot directory exists
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Always ensure .gitignore exists in .browser-pilot
const gitignorePath = join(outputDir, '.gitignore');
if (!existsSync(gitignorePath)) {
writeFileSync(gitignorePath, FS.GITIGNORE_CONTENT, 'utf-8');
}
return outputDir;
}
/**
* Load shared configuration from plugin folder
* Auto-creates default config if not exists
*/
export function loadSharedConfig(): SharedBrowserPilotConfig {
const configPath = getSharedConfigPath();
if (!existsSync(configPath)) {
// Auto-create default config
const defaultConfig: SharedBrowserPilotConfig = {
projects: {}
};
saveSharedConfig(defaultConfig);
return defaultConfig;
}
try {
const data = readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
logger.error('Failed to load shared config', error);
logger.warn('Returning empty config - existing project settings may be lost');
logger.warn(`Config path: ${configPath}`);
return {
projects: {}
};
}
}
/**
* Save shared configuration to plugin folder
*/
export function saveSharedConfig(config: SharedBrowserPilotConfig): void {
const configPath = getSharedConfigPath();
try {
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to save shared config', error);
logger.warn(`Config path: ${configPath}`);
throw new Error('Configuration save failed. Please check file permissions.');
}
}
/**
* Get configuration for current project
* Auto-creates with available port if not exists
*/
export async function getProjectConfig(): Promise<ProjectConfig> {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
// Find existing config by rootPath (in case name changed)
const existingEntry = Object.entries(sharedConfig.projects).find(
([_, config]) => config.rootPath === projectRoot
);
if (existingEntry) {
const [existingName, config] = existingEntry;
// If name changed, update key
if (existingName !== projectName) {
delete sharedConfig.projects[existingName];
sharedConfig.projects[projectName] = config;
saveSharedConfig(sharedConfig);
logger.info(`📝 Updated project name: ${existingName}${projectName}`);
}
return config;
}
// Check if name already exists (different path)
if (sharedConfig.projects[projectName]) {
logger.warn(`⚠️ Project name "${projectName}" already exists with different path`);
logger.warn(` Existing: ${sharedConfig.projects[projectName].rootPath}`);
logger.warn(` Current: ${projectRoot}`);
throw new Error(`Project name conflict: "${projectName}"`);
}
// Create new project config with available port
const basePort = parseInt(process.env.CDP_DEBUG_PORT || String(CDP.DEFAULT_PORT));
// Find next available port that's not used by any project
const usedPorts = Object.values(sharedConfig.projects).map(p => p.port);
let port = basePort;
// Find first available port not in use by other projects
while (usedPorts.includes(port) || !(await isPortAvailable(port))) {
port++;
if (port > basePort + CDP.PORT_RANGE_MAX) {
throw new Error(`No available port found in range ${basePort}-${basePort + CDP.PORT_RANGE_MAX}`);
}
}
const projectConfig: ProjectConfig = {
rootPath: projectRoot,
port,
outputDir: FS.OUTPUT_DIR,
lastUsed: getLocalTimestamp(),
autoCleanup: false // Default to false for safety
};
// Save new project config
sharedConfig.projects[projectName] = projectConfig;
saveSharedConfig(sharedConfig);
logger.info(`📝 Created config for project: ${projectName}`);
logger.info(` Path: ${projectRoot}`);
logger.info(` Port: ${port}`);
return projectConfig;
}
/**
* Update last used timestamp for current project
*/
export function updateProjectLastUsed(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
if (sharedConfig.projects[projectName]) {
sharedConfig.projects[projectName].lastUsed = getLocalTimestamp();
saveSharedConfig(sharedConfig);
}
}
/**
* Get debug port for current project
*/
export async function getProjectPort(): Promise<number> {
const config = await getProjectConfig();
return config.port;
}
/**
* Clean up project config if autoCleanup is enabled
*/
export function cleanupProjectIfNeeded(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
const projectConfig = sharedConfig.projects[projectName];
if (projectConfig && projectConfig.autoCleanup) {
delete sharedConfig.projects[projectName];
saveSharedConfig(sharedConfig);
logger.info(`🗑️ Auto-cleaned config for project: ${projectName}`);
}
}
/**
* Set autoCleanup flag for current project
*/
export function setAutoCleanup(enabled: boolean): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
if (sharedConfig.projects[projectName]) {
sharedConfig.projects[projectName].autoCleanup = enabled;
saveSharedConfig(sharedConfig);
logger.info(`${enabled ? '✅' : '❌'} Auto-cleanup ${enabled ? 'enabled' : 'disabled'} for: ${projectName}`);
}
}
/**
* Reset configuration for current project
*/
export function resetProjectConfig(): void {
const projectRoot = findProjectRoot();
const projectName = getProjectName(projectRoot);
const sharedConfig = loadSharedConfig();
delete sharedConfig.projects[projectName];
saveSharedConfig(sharedConfig);
logger.info(`🗑️ Removed config for project: ${projectName}`);
}
/**
* List all configured projects
*/
export function listProjects(): void {
const sharedConfig = loadSharedConfig();
const projects = Object.entries(sharedConfig.projects);
if (projects.length === 0) {
logger.info('No projects configured yet.');
return;
}
logger.info(`\n📋 Configured Projects (${projects.length}):\n`);
projects.forEach(([name, config]) => {
logger.info(` ${name}`);
logger.info(` ├─ Path: ${config.rootPath}`);
logger.info(` ├─ Port: ${config.port}`);
logger.info(` ├─ Output: ${config.outputDir}`);
logger.info(` ├─ Auto-cleanup: ${config.autoCleanup ? 'Yes' : 'No'}`);
logger.info(` └─ Last Used: ${config.lastUsed || 'Never'}\n`);
});
}
/**
* Check if a port is available (not in use)
*/
export async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
// Listen on 127.0.0.1 specifically (same as Chrome)
server.listen(port, CDP.LOCALHOST);
});
}
/**
* Find an available port starting from startPort
*/
export async function findAvailablePort(startPort = CDP.DEFAULT_PORT, maxAttempts = 10): Promise<number> {
for (let port = startPort; port < startPort + maxAttempts; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
}

View File

@@ -0,0 +1,309 @@
/**
* Browser script to generate interaction map.
* This script runs in the browser context via Runtime.evaluate.
*/
export interface InteractionElement {
id: string;
type: string;
tag: string;
text?: string;
value?: string;
selectors: {
byText?: string; // Text-based XPath: //*[contains(text(), '...')]
byId?: string; // ID selector: #button-id
byCSS?: string; // CSS selector: button.class-name
byRole?: string; // Role-based: button, input, etc.
byAriaLabel?: string; // ARIA label: [aria-label="..."]
};
attributes: Record<string, unknown>;
position: { x: number; y: number };
visibility: {
inViewport: boolean;
visible: boolean;
obscured: boolean;
};
context?: {
parent?: string;
section?: string;
};
}
/**
* Generate the browser script that finds all interactive elements.
* Returns a string that can be executed via Runtime.evaluate.
*/
export function getInteractionMapScript(): string {
return `
(function() {
const elements = [];
let idCounter = 0;
// Helper: Escape text for XPath (handles both single and double quotes)
function escapeXPath(text) {
// If no quotes, use single quotes
if (!text.includes("'") && !text.includes('"')) {
return "'" + text + "'";
}
// If only single quotes, use double quotes
if (text.includes("'") && !text.includes('"')) {
return '"' + text + '"';
}
// If only double quotes, use single quotes
if (!text.includes("'") && text.includes('"')) {
return "'" + text + "'";
}
// Both present: use concat()
const parts = text.split("'");
const escaped = parts.map((part, i) => {
if (i === 0) return "'" + part + "'";
return "\\"'\\"," + "'" + part + "'";
}).join(',');
return "concat(" + escaped + ")";
}
// Helper: Generate Browser Pilot compatible selectors
function getBrowserPilotSelectors(el) {
const selectors = {};
// 1. Text-based XPath (most stable for Browser Pilot)
const text = el.textContent?.trim();
if (text && text.length > 0 && text.length <= 50) {
const tagName = el.tagName.toLowerCase();
selectors.byText = "//" + tagName + "[contains(text(), " + escapeXPath(text) + ")]";
}
// 2. ID selector (best if available)
if (el.id) {
selectors.byId = '#' + el.id;
}
// 3. CSS selector (with safe classes only)
if (el.className) {
const className = typeof el.className === 'string'
? el.className
: (el.className.baseVal || '');
if (className && className.trim) {
const classes = className.trim().split(/\\s+/)
.filter(cls => /^[a-zA-Z0-9_-]+$/.test(cls))
.slice(0, 3);
if (classes.length > 0) {
selectors.byCSS = el.tagName.toLowerCase() + '.' + classes.join('.');
}
}
}
// Fallback CSS selector (tag only)
if (!selectors.byCSS) {
selectors.byCSS = el.tagName.toLowerCase();
}
// 4. Role-based selector
const role = el.getAttribute('role');
if (role) {
selectors.byRole = '[role="' + role + '"]';
}
// 5. ARIA label selector
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
// For CSS selectors, escape both quotes properly
const escapedLabel = ariaLabel.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'").replace(/"/g, '\\\\"');
selectors.byAriaLabel = "[aria-label='" + escapedLabel + "']";
}
return selectors;
}
// Helper: Check if element is actually visible and interactive
function isInteractive(el) {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
// Check visibility
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
// Check size
if (rect.width === 0 || rect.height === 0) {
return false;
}
// Check pointer events (but allow standard interactive elements even if disabled)
const isStandardInteractive = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'A'].includes(el.tagName);
if (style.pointerEvents === 'none' && !isStandardInteractive) {
return false;
}
return true;
}
// Helper: Check if element is obscured
function isObscured(el) {
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const topElement = document.elementFromPoint(centerX, centerY);
return topElement !== el && !el.contains(topElement);
}
// Helper: Get parent context
function getContext(el) {
const context = {};
// Find parent with aria-label or role
let parent = el.parentElement;
while (parent && parent !== document.body) {
if (parent.getAttribute('aria-label')) {
context.section = parent.getAttribute('aria-label');
break;
}
if (parent.getAttribute('role') === 'group' || parent.getAttribute('role') === 'region') {
context.parent = parent.tagName.toLowerCase();
break;
}
parent = parent.parentElement;
}
return context;
}
// Helper: Check if element has React/Vue event handlers
function hasEventHandlers(el) {
// React event handlers
if (el._reactProps?.onClick) return true;
if (el.__reactEventHandlers$?.onClick) return true;
// Check for React fiber props
const keys = Object.keys(el);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key.startsWith('__reactProps') || key.startsWith('__reactEventHandlers')) {
const props = el[key];
if (props && props.onClick) return true;
}
}
return false;
}
// Find all interactive elements
const selectors = [
// Standard interactive elements
'button',
'a[href]',
'input',
'select',
'textarea',
// ARIA roles (only interactive ones)
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="radiogroup"]',
'[role="combobox"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="switch"]',
'[role="slider"]',
// Event handlers
'[onclick]',
'[onmousedown]',
'[onmouseup]'
];
const foundElements = new Set();
// Collect elements by selector
selectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
if (!foundElements.has(el) && isInteractive(el)) {
foundElements.add(el);
}
});
});
// Additional: Find clickable elements by cursor:pointer or React handlers
// Check ALL elements for cursor:pointer or event handlers
document.querySelectorAll('*').forEach(el => {
if (foundElements.has(el)) return;
const style = window.getComputedStyle(el);
// Include if has cursor:pointer AND is interactive
if (style.cursor === 'pointer' && isInteractive(el)) {
foundElements.add(el);
return;
}
// Include if has React event handlers (only check button-like classes for performance)
if (el.className && typeof el.className === 'string') {
const hasButtonClass = /button|btn|card|item|clickable/.test(el.className);
if (hasButtonClass && hasEventHandlers(el) && isInteractive(el)) {
foundElements.add(el);
}
}
});
// Process found elements
Array.from(foundElements).forEach(el => {
const rect = el.getBoundingClientRect();
// Calculate absolute position (viewport coordinates + scroll offset)
const absoluteX = Math.round(rect.left + window.pageXOffset + rect.width / 2);
const absoluteY = Math.round(rect.top + window.pageYOffset + rect.height / 2);
// Check if element is currently in viewport
const inViewport = (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
const element = {
id: 'elem_' + (idCounter++),
type: el.tagName.toLowerCase() === 'input' ? 'input-' + (el.type || 'text') :
el.tagName.toLowerCase() === 'select' ? 'select' :
el.tagName.toLowerCase() === 'textarea' ? 'textarea' :
el.getAttribute('role') ? 'role-' + el.getAttribute('role') :
el.tagName.toLowerCase(),
tag: el.tagName.toLowerCase(),
text: el.textContent?.trim().substring(0, 100) || null,
value: el.value || null,
selectors: getBrowserPilotSelectors(el),
attributes: {
id: el.id || null,
class: el.className || null,
name: el.getAttribute('name') || null,
type: el.getAttribute('type') || null,
role: el.getAttribute('role') || null,
'aria-label': el.getAttribute('aria-label') || null,
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
placeholder: el.getAttribute('placeholder') || null
},
position: {
x: absoluteX,
y: absoluteY
},
visibility: {
inViewport: inViewport,
visible: true,
obscured: inViewport ? isObscured(el) : false
},
context: getContext(el)
};
elements.push(element);
});
return elements;
})()
`;
}

View File

@@ -0,0 +1,386 @@
/**
* Query interaction map to find elements by various criteria
*/
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import { InteractionElement } from './generate-interaction-map';
import { SELECTOR_RETRY_CONFIG } from '../actions/helpers';
import { getOutputDir } from '../config';
import { CDP, TIMING } from '../../constants';
import { logger } from '../../utils/logger';
// Re-export for protocol usage
export type { InteractionElement };
export interface InteractionMap {
url: string;
timestamp: string;
ready?: boolean; // Map is fully generated and ready for use
viewport: { width: number; height: number };
elements: Record<string, InteractionElement>;
indexes: {
byText: Record<string, string[]>;
byType: Record<string, string[]>;
inViewport: string[];
};
statistics: {
total: number;
byType: Record<string, number>;
duplicates: number;
};
}
export interface QueryOptions {
text?: string; // Search by text content
type?: string; // Filter by element type (supports aliases: "input" → "input-*")
tag?: string; // Filter by HTML tag (e.g., "input", "button")
index?: number; // Select nth match (1-based)
viewportOnly?: boolean; // Only visible elements
id?: string; // Direct ID lookup
listTypes?: boolean; // List all element types with counts
listTexts?: boolean; // List all text contents
limit?: number; // Maximum results to return (default: 20)
offset?: number; // Number of results to skip (default: 0)
verbose?: boolean; // Include detailed information
}
export interface QueryResult {
element: InteractionElement;
selector: string; // Best selector to use
alternatives: string[]; // Alternative selectors
}
/**
* Load interaction map from file with ready flag check
* @param mapPath Optional path to map file
* @param waitForReady If true, poll until map is ready (default: false)
* @param timeout Maximum wait time in milliseconds (default: 10000)
*/
export function loadMap(
mapPath?: string,
waitForReady: boolean = false,
timeout: number = 10000
): InteractionMap {
const defaultPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
const filePath = mapPath || defaultPath;
if (!fs.existsSync(filePath)) {
throw new Error(`Map file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const map = JSON.parse(content) as InteractionMap;
// If not waiting for ready or already ready, return immediately
if (!waitForReady || map.ready === true) {
return map;
}
// Poll until ready or timeout
const startTime = Date.now();
const pollInterval = TIMING.POLLING_INTERVAL_FAST;
while (Date.now() - startTime < timeout) {
// Sleep using platform-appropriate command
try {
if (process.platform === 'win32') {
execSync(`ping -n 1 -w ${pollInterval} ${CDP.LOCALHOST} >nul`, { stdio: 'ignore' });
} else {
execSync(`sleep ${pollInterval / TIMING.ACTION_DELAY_NAVIGATION}`, { stdio: 'ignore' });
}
} catch {
// Ignore errors, just continue polling
}
// Re-read map
if (!fs.existsSync(filePath)) {
throw new Error(`Map file disappeared during polling: ${filePath}`);
}
const newContent = fs.readFileSync(filePath, 'utf-8');
const newMap = JSON.parse(newContent) as InteractionMap;
if (newMap.ready === true) {
return newMap;
}
}
throw new Error(`Map did not become ready within ${timeout}ms`);
}
/**
* Select best selector for an element
* Priority: byId > byText(indexed) > byCSS > byRole > byAriaLabel
*/
export function selectBestSelector(element: InteractionElement): string {
const { selectors } = element;
if (selectors.byId) {
return selectors.byId;
}
if (selectors.byText) {
return selectors.byText;
}
if (selectors.byCSS && selectors.byCSS !== element.tag) {
// Skip generic tag-only selectors
return selectors.byCSS;
}
if (selectors.byRole) {
return selectors.byRole;
}
if (selectors.byAriaLabel) {
return selectors.byAriaLabel;
}
// Fallback to CSS
return selectors.byCSS || element.tag;
}
/**
* Get all alternative selectors for an element
*/
export function getAlternativeSelectors(element: InteractionElement): string[] {
const alternatives: string[] = [];
const { selectors } = element;
if (selectors.byId) alternatives.push(selectors.byId);
if (selectors.byText) alternatives.push(selectors.byText);
if (selectors.byCSS) alternatives.push(selectors.byCSS);
if (selectors.byRole) alternatives.push(selectors.byRole);
if (selectors.byAriaLabel) alternatives.push(selectors.byAriaLabel);
return alternatives;
}
/**
* Escape special regex characters in a string
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Expand type alias to include all matching types
* Examples:
* - "input" → ["input", "input-text", "input-search", "input-password", ...]
* - "button" → ["button", "button-submit", "button-reset", ...]
* - "input-search" → ["input-search"] (exact match, no expansion)
*/
export function expandTypeAlias(type: string, availableTypes: string[]): string[] {
// If type contains a hyphen, it's a specific type (no expansion)
if (type.includes('-')) {
return [type];
}
// Escape regex special characters to prevent regex injection
const escapedType = escapeRegex(type);
// Expand to include all types starting with the alias
const pattern = new RegExp(`^${escapedType}(-.*)?$`);
const matches = availableTypes.filter(t => pattern.test(t));
// If no matches found, return original type
return matches.length > 0 ? matches : [type];
}
/**
* Query map for elements matching criteria
*/
export function queryMap(map: InteractionMap, options: QueryOptions): QueryResult[] {
let candidateIds: string[] = [];
// Direct ID lookup
if (options.id) {
const element = map.elements[options.id];
if (!element) {
return [];
}
return [{
element,
selector: selectBestSelector(element),
alternatives: getAlternativeSelectors(element)
}];
}
// Text-based search
if (options.text) {
candidateIds = map.indexes.byText[options.text] || [];
if (candidateIds.length === 0) {
// Fuzzy search: find texts containing the query
const matchingTexts = Object.keys(map.indexes.byText).filter(text =>
options.text && text.toLowerCase().includes(options.text.toLowerCase())
);
matchingTexts.forEach(text => {
candidateIds.push(...map.indexes.byText[text]);
});
}
} else {
// No text filter: start with all elements
candidateIds = Object.keys(map.elements);
}
// Type filter (with alias expansion)
if (options.type) {
const availableTypes = Object.keys(map.indexes.byType);
const expandedTypes = expandTypeAlias(options.type, availableTypes);
const typeIds = expandedTypes.flatMap(type => map.indexes.byType[type] || []);
candidateIds = candidateIds.filter(id => typeIds.includes(id));
// Log type expansion if expansion occurred
if (expandedTypes.length > 1) {
logger.debug(`Type alias "${options.type}" expanded to: ${expandedTypes.join(', ')}`);
}
}
// Tag filter (HTML tag name)
if (options.tag) {
const tagLower = options.tag.toLowerCase();
candidateIds = candidateIds.filter(id => {
const element = map.elements[id];
return element && element.tag.toLowerCase() === tagLower;
});
}
// Viewport filter
if (options.viewportOnly) {
const viewportIds = map.indexes.inViewport;
candidateIds = candidateIds.filter(id => viewportIds.includes(id));
}
// Remove duplicates
candidateIds = Array.from(new Set(candidateIds));
// Convert IDs to QueryResults
const results: QueryResult[] = candidateIds.map(id => {
const element = map.elements[id];
return {
element,
selector: selectBestSelector(element),
alternatives: getAlternativeSelectors(element)
};
});
// Apply pagination (limit/offset)
const limit = options.limit !== undefined ? options.limit : 20;
const offset = options.offset || 0;
// Index selection (takes priority over pagination)
if (options.index !== undefined && options.index > 0) {
const selected = results[options.index - 1];
return selected ? [selected] : [];
}
// Apply offset and limit
if (limit === 0) {
// 0 means unlimited
return results.slice(offset);
}
return results.slice(offset, offset + limit);
}
/**
* Find element and return best selector
* @param mapPath Path to map file
* @param options Query options
* @param waitForReady If true, wait for map to be ready before querying (default: true)
* @param timeout Maximum wait time in milliseconds (default: 10000)
*/
export function findSelector(
mapPath: string | undefined,
options: QueryOptions,
waitForReady: boolean = true,
timeout: number = 10000
): string | null {
try {
const map = loadMap(mapPath, waitForReady, timeout);
const results = queryMap(map, options);
if (results.length === 0) {
return null;
}
// Return the best selector of the first result
return results[0].selector;
} catch (error: unknown) {
logger.error('Error querying map', error);
return null;
}
}
/**
* Find element with fallback to alternatives
*/
export function findSelectorWithFallback(
mapPath: string | undefined,
options: QueryOptions
): { selector: string; alternatives: string[] } | null {
try {
const map = loadMap(mapPath);
const results = queryMap(map, options);
if (results.length === 0) {
return null;
}
return {
selector: results[0].selector,
alternatives: results[0].alternatives
};
} catch (error: unknown) {
logger.error('Error querying map', error);
return null;
}
}
/**
* List all element types with counts from map
*/
export function listTypes(map: InteractionMap): Record<string, number> {
return map.statistics.byType;
}
/**
* List all text contents with their types from map
*/
export function listTexts(
map: InteractionMap,
options?: { type?: string; limit?: number; offset?: number }
): Array<{ text: string; type: string; count: number }> {
const limit = options?.limit !== undefined ? options.limit : 20;
const offset = options?.offset || 0;
const typeFilter = options?.type;
const textList: Array<{ text: string; type: string; count: number }> = [];
// Iterate through text index
for (const [text, elementIds] of Object.entries(map.indexes.byText)) {
// Get first element to determine type
const firstElement = map.elements[elementIds[0]];
if (!firstElement) continue;
// Apply type filter if specified
if (typeFilter && firstElement.type !== typeFilter) {
continue;
}
textList.push({
text,
type: firstElement.type,
count: elementIds.length
});
}
// Apply pagination
if (limit === 0) {
return textList.slice(offset);
}
return textList.slice(offset, offset + limit);
}

View File

@@ -0,0 +1,179 @@
/**
* Utility functions for Browser Pilot
*/
import { readFileSync, existsSync } from 'fs';
import { join, normalize, resolve } from 'path';
import { logger } from '../utils/logger';
import { TIMING } from '../constants';
interface ProjectConfig {
rootPath: string;
port: number;
outputDir: string;
lastUsed: string | null;
autoCleanup: boolean;
}
interface SharedBrowserPilotConfig {
projects: {
[projectName: string]: ProjectConfig;
};
}
/**
* Get shared config file path in plugin skills folder
* Uses hardcoded home directory path for reliability
*/
function getSharedConfigPath(): string {
const { homedir } = require('os');
const homeDir = homedir();
return join(
homeDir,
'.claude',
'plugins',
'marketplaces',
'dev-gom-plugins',
'plugins',
'browser-pilot',
'skills',
'browser-pilot-config.json'
);
}
/**
* Load shared configuration from plugin folder
*/
function loadSharedConfig(): SharedBrowserPilotConfig {
const configPath = getSharedConfigPath();
if (!existsSync(configPath)) {
return { projects: {} };
}
try {
const data = readFileSync(configPath, 'utf-8');
return JSON.parse(data);
} catch (_error) {
return { projects: {} };
}
}
/**
* Compare two paths for equality (cross-platform, case-insensitive on Windows)
*/
function pathsEqual(path1: string, path2: string): boolean {
return normalize(resolve(path1)).toLowerCase() ===
normalize(resolve(path2)).toLowerCase();
}
/**
* Get project root directory.
*
* Strategy (in order of priority):
* 1. CLAUDE_PROJECT_DIR environment variable
* 2. Shared config file (if running from scripts folder)
*
* No fallback to process.cwd() - requires explicit project configuration.
*/
export function findProjectRoot(): string {
// 1. Environment variable has highest priority
if (process.env.CLAUDE_PROJECT_DIR) {
return process.env.CLAUDE_PROJECT_DIR;
}
const cwd = process.cwd();
// 2. If running from scripts folder, check shared config
// More robust check: compare exact path (cross-platform, case-insensitive)
const scriptsDir = join(__dirname, '..', '..');
if (pathsEqual(cwd, scriptsDir)) {
try {
const config = loadSharedConfig();
const projects = Object.values(config.projects);
if (projects.length === 1) {
// Only one project configured, use it
return projects[0].rootPath;
} else if (projects.length > 1) {
// Multiple projects: use the most recently used one
const sorted = projects.sort((a, b) => {
// Handle invalid dates: treat as 0 to ensure predictable sorting
const aTime = new Date(a.lastUsed || 0).getTime();
const bTime = new Date(b.lastUsed || 0).getTime();
return (isNaN(bTime) ? 0 : bTime) - (isNaN(aTime) ? 0 : aTime);
});
return sorted[0].rootPath;
}
} catch (error) {
logger.error(`Failed to load shared config: ${error}`);
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set and no projects in shared config');
}
}
// No fallback to process.cwd() - require explicit project configuration
throw new Error('Could not determine project root: CLAUDE_PROJECT_DIR not set');
}
/**
* Returns the findElement helper function as a JavaScript string
* for injection into browser context.
*
* Supports:
* - CSS selectors: 'button.primary'
* - XPath selectors: '//button[@id="submit"]'
* - XPath with indexing: '(//button[text()="Click"])[2]'
*/
export function getFindElementScript(): string {
return `
function findElement(sel) {
if (sel.startsWith('//') || sel.startsWith('(//')) {
// XPath selector - check for indexing pattern: (...)[N]
const indexMatch = sel.match(/^\\((.*)\\)\\[(\\d+)\\]$/);
if (indexMatch) {
// Has indexing: (//xpath)[N]
const xpath = indexMatch[1];
const index = parseInt(indexMatch[2]) - 1; // XPath is 1-based, JS is 0-based
const result = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
return result.snapshotItem(index);
} else {
// No indexing - return first match
const result = document.evaluate(
sel,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue;
}
} else {
// CSS selector
return document.querySelector(sel);
}
}
`;
}
/**
* Human-like random delay to avoid bot detection
* @param minMs Minimum delay in milliseconds (default: ACTION_DELAY_MEDIUM * 3)
* @param maxMs Maximum delay in milliseconds (default: ACTION_DELAY_LONG + ACTION_DELAY_MEDIUM * 3)
* @returns Promise that resolves after the delay
*/
export function humanDelay(
minMs: number = TIMING.ACTION_DELAY_MEDIUM * 3,
maxMs: number = TIMING.ACTION_DELAY_LONG + TIMING.ACTION_DELAY_MEDIUM * 3
): Promise<void> {
const delayMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return new Promise(resolve => setTimeout(resolve, delayMs));
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* CDP Browser CLI - Chrome DevTools Protocol browser automation tool.
*/
import { Command } from 'commander';
import { registerNavigationCommands } from './commands/navigation';
import { registerInteractionCommands } from './commands/interaction';
import { registerFormsCommands } from './commands/forms';
import { registerCaptureCommands } from './commands/capture';
import { registerTabsCommands } from './commands/tabs';
import { registerCookiesCommands } from './commands/cookies';
import { registerConsoleCommands } from './commands/console';
import { registerNetworkCommands } from './commands/network';
import { registerEmulationCommands } from './commands/emulation';
import { registerDialogsCommands } from './commands/dialogs';
import { registerScrollCommands } from './commands/scroll';
import { registerWaitCommands } from './commands/wait';
import { registerDataCommands } from './commands/data';
import { registerFocusCommands } from './commands/focus';
import { registerAccessibilityCommands } from './commands/accessibility';
import { registerDaemonCommands } from './commands/daemon';
import { registerChainCommands } from './commands/chain';
import { registerQueryCommands } from './commands/query';
import { registerSystemCommands } from './commands/system';
const program = new Command();
program
.name('cdp-browser')
.description('Chrome DevTools Protocol browser automation CLI')
.version('1.0.0')
.addHelpText('after', '\nTip: Use "<command> --help" to see detailed options for each command.\nExample: cdp-browser navigate --help');
// Register all command groups
registerDaemonCommands(program); // Daemon management first
registerSystemCommands(program); // System maintenance commands
registerChainCommands(program); // Chain mode for sequential execution
registerNavigationCommands(program);
registerInteractionCommands(program);
registerFormsCommands(program);
registerCaptureCommands(program);
registerTabsCommands(program);
registerCookiesCommands(program);
registerConsoleCommands(program);
registerNetworkCommands(program);
registerEmulationCommands(program);
registerDialogsCommands(program);
registerScrollCommands(program);
registerWaitCommands(program);
registerDataCommands(program);
registerFocusCommands(program);
registerAccessibilityCommands(program);
registerQueryCommands(program); // Query interaction map
// Parse command line arguments
program.parse();

View File

@@ -0,0 +1,30 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
export function registerAccessibilityCommands(program: Command) {
// Get accessibility snapshot
program
.command('accessibility')
.description('Get accessibility tree snapshot (ARIA roles, labels, and screen reader info)')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getAccessibilitySnapshot(browser);
console.log(`Accessibility nodes: ${result.nodeCount}`);
console.log('First 50 nodes:', JSON.stringify(result.nodes, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
logger.error('Command execution failed', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,237 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
/**
* Type guard for viewport response data
*/
function isViewportResponse(data: unknown): data is { viewport: { width: number; height: number; devicePixelRatio: number } } {
return (
typeof data === 'object' &&
data !== null &&
'viewport' in data &&
typeof (data as Record<string, unknown>).viewport === 'object' &&
(data as Record<string, unknown>).viewport !== null &&
'width' in ((data as Record<string, unknown>).viewport as object) &&
'height' in ((data as Record<string, unknown>).viewport as object) &&
'devicePixelRatio' in ((data as Record<string, unknown>).viewport as object) &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).width === 'number' &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).height === 'number' &&
typeof ((data as Record<string, unknown>).viewport as Record<string, unknown>).devicePixelRatio === 'number'
);
}
/**
* Type guard for screen info response data
*/
function isScreenInfoResponse(data: unknown): data is {
viewport: { width: number; height: number };
screen: { width: number; height: number; availWidth: number; availHeight: number };
devicePixelRatio: number;
} {
if (typeof data !== 'object' || data === null) return false;
const d = data as Record<string, unknown>;
// Check viewport
if (typeof d.viewport !== 'object' || d.viewport === null) return false;
const viewport = d.viewport as Record<string, unknown>;
if (typeof viewport.width !== 'number' || typeof viewport.height !== 'number') return false;
// Check screen
if (typeof d.screen !== 'object' || d.screen === null) return false;
const screen = d.screen as Record<string, unknown>;
if (
typeof screen.width !== 'number' ||
typeof screen.height !== 'number' ||
typeof screen.availWidth !== 'number' ||
typeof screen.availHeight !== 'number'
) return false;
// Check devicePixelRatio
if (typeof d.devicePixelRatio !== 'number') return false;
return true;
}
export function registerCaptureCommands(program: Command) {
// Screenshot command
program
.command('screenshot')
.description('Capture screenshot of webpage (saved to .browser-pilot/screenshots/)')
.option('-u, --url <url>', 'URL to capture (optional, uses current page if not specified)')
.option('-o, --output <path>', 'Output file path', 'screenshot.png')
.option('--headless', 'Run in headless mode', false)
.option('--full-page', 'Capture full page', true)
.option('--clip-x <x>', 'Clip region X coordinate (pixels)')
.option('--clip-y <y>', 'Clip region Y coordinate (pixels)')
.option('--clip-width <width>', 'Clip region width (pixels)')
.option('--clip-height <height>', 'Clip region height (pixels)')
.option('--clip-scale <scale>', 'Clip region scale factor (default: 1)', '1')
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Build screenshot params
const params: Record<string, unknown> = {
filename: options.output,
fullPage: options.fullPage
};
// Add clip options if provided
if (options.clipX && options.clipY && options.clipWidth && options.clipHeight) {
params.clipX = parseFloat(options.clipX);
params.clipY = parseFloat(options.clipY);
params.clipWidth = parseFloat(options.clipWidth);
params.clipHeight = parseFloat(options.clipHeight);
params.clipScale = parseFloat(options.clipScale);
}
// Take screenshot
const response = await executeViaDaemon('screenshot', params);
if (response.success) {
const data = response.data as { success: boolean; path: string };
console.log('Screenshot saved:', data.path);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Screenshot failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Set viewport size command
program
.command('set-viewport')
.description('Set browser viewport size')
.requiredOption('-w, --width <width>', 'Viewport width in pixels')
.requiredOption('-h, --height <height>', 'Viewport height in pixels')
.option('--scale <scale>', 'Device scale factor (default: 1)', '1')
.option('--mobile', 'Emulate mobile device', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('set-viewport', {
width: parseInt(options.width),
height: parseInt(options.height),
deviceScaleFactor: parseFloat(options.scale),
mobile: options.mobile
});
if (response.success) {
const data = response.data as { width: number; height: number };
console.log(`Viewport size set to: ${data.width}x${data.height}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Set viewport failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get viewport command
program
.command('get-viewport')
.description('Get current viewport size')
.action(async () => {
try {
const response = await executeViaDaemon('get-viewport', {});
if (response.success) {
if (!isViewportResponse(response.data)) {
console.error('Get viewport failed: Invalid response format');
process.exit(1);
}
const data = response.data;
console.log('=== Viewport Information ===');
console.log(`Size: ${data.viewport.width}x${data.viewport.height}`);
console.log(`Scale: ${data.viewport.devicePixelRatio}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Get viewport failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get screen info command
program
.command('get-screen-info')
.description('Get screen and viewport information')
.action(async () => {
try {
const response = await executeViaDaemon('get-screen-info', {});
if (response.success) {
if (!isScreenInfoResponse(response.data)) {
console.error('Get screen info failed: Invalid response format');
process.exit(1);
}
const data = response.data;
console.log('=== Screen Information ===');
console.log(`Screen: ${data.screen.width}x${data.screen.height}`);
console.log(`Available: ${data.screen.availWidth}x${data.screen.availHeight}`);
console.log(`Viewport: ${data.viewport.width}x${data.viewport.height}`);
console.log(`Scale: ${data.devicePixelRatio}`);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Get screen info failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Generate PDF command
program
.command('pdf')
.description('Generate PDF from webpage (saved to .browser-pilot/pdfs/)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-o, --output <path>', 'Output file path', 'page.pdf')
.option('--headless', 'Run in headless mode', false)
.option('--landscape', 'Use landscape orientation', false)
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Generate PDF
const response = await executeViaDaemon('pdf', {
filename: options.output,
landscape: options.landscape
});
if (response.success) {
const data = response.data as { success: boolean; path: string };
console.log('PDF saved:', data.path);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('PDF generation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,438 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
import { findSelector } from '../../cdp/map/query-map';
import { findSelectorWithRetry } from './selector-helper';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { getOutputDir } from '../../cdp/config';
import * as path from 'path';
/**
* List of all available commands
*/
const AVAILABLE_COMMANDS = [
// Navigation
'navigate', 'back', 'forward', 'reload',
// Interaction
'click', 'hover', 'press', 'type', 'upload',
// Forms
'fill', 'select', 'check', 'uncheck',
// Capture
'screenshot', 'pdf',
// Tabs
'tabs', 'new-tab', 'close-tab', 'switch-tab', 'close',
// Cookies
'cookies', 'set-cookie', 'delete-cookies',
// Console
'console',
// Scroll
'scroll',
// Wait
'wait', 'wait-idle', 'sleep',
// Data extraction
'extract', 'content', 'extract-data', 'find', 'get-property',
// Focus
'focus', 'blur',
// Accessibility
'accessibility',
// Network
'block-url', 'unblock-urls',
// Dialogs
'dialog', 'enable-interception', 'disable-interception',
// Emulation
'emulate-media',
// Drag
'drag'
];
interface ChainCommand {
command: string;
args: string[];
}
/**
* Parse raw arguments into command groups
*/
function parseChainArgs(rawArgs: string[]): ChainCommand[] {
const commands: ChainCommand[] = [];
let currentCommand: ChainCommand | null = null;
for (const arg of rawArgs) {
// Check if this is a command name
if (AVAILABLE_COMMANDS.includes(arg)) {
// Save previous command
if (currentCommand) {
commands.push(currentCommand);
}
// Start new command
currentCommand = {
command: arg,
args: []
};
} else if (currentCommand) {
// Add argument to current command
currentCommand.args.push(arg);
} else {
// Argument before any command (error)
throw new Error(`Unexpected argument "${arg}" before any command`);
}
}
// Save last command
if (currentCommand) {
commands.push(currentCommand);
}
return commands;
}
/**
* Parse command arguments into params object
*/
function parseCommandArgs(command: string, args: string[]): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// Long option: --option value or --option=value
if (arg.startsWith('--')) {
const optionName = arg.slice(2);
// Check for --option=value format
if (optionName.includes('=')) {
const [key, value] = optionName.split('=', 2);
params[key] = value;
continue;
}
// Check if next arg is a value (doesn't start with -)
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('-')) {
// Parse value type
let value: unknown = nextArg;
// Try to parse as number
const num = Number(nextArg);
if (!isNaN(num)) {
value = num;
}
// Check for boolean strings
else if (nextArg === 'true') {
value = true;
} else if (nextArg === 'false') {
value = false;
}
params[optionName] = value;
i++; // Skip next arg
} else {
// Boolean flag
params[optionName] = true;
}
}
// Short option: -s value or -s=value
else if (arg.startsWith('-') && arg.length > 1) {
const optionName = arg.slice(1);
// Check for -s=value format
if (optionName.includes('=')) {
const [key, value] = optionName.split('=', 2);
params[key] = value;
continue;
}
// Check if next arg is a value
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('-')) {
// Parse value type
let value: unknown = nextArg;
const num = Number(nextArg);
if (!isNaN(num)) {
value = num;
} else if (nextArg === 'true') {
value = true;
} else if (nextArg === 'false') {
value = false;
}
params[optionName] = value;
i++;
} else {
params[optionName] = true;
}
}
// Positional argument
else {
// Store as _args array
if (!params._args) {
params._args = [];
}
(params._args as unknown[]).push(arg);
}
}
return params;
}
/**
* Convert parsed params to daemon-compatible format
*/
function convertParamsForDaemon(command: string, params: Record<string, unknown>): Record<string, unknown> {
const converted: Record<string, unknown> = { ...params };
// Map common option names
const mappings: Record<string, string> = {
'u': 'url',
's': 'selector',
'v': 'value',
't': 'timeout',
'k': 'key',
'o': 'output',
'f': 'file',
'i': 'index',
'x': 'x',
'y': 'y',
'e': 'expression',
'd': 'delay'
};
// Apply mappings
for (const [short, long] of Object.entries(mappings)) {
if (short in converted && !(long in converted)) {
converted[long] = converted[short];
delete converted[short];
}
}
// Handle Smart Mode options for click/fill
if (command === 'click' || command === 'fill') {
// Convert kebab-case to camelCase
if ('viewport-only' in converted) {
converted.viewportOnly = converted['viewport-only'];
delete converted['viewport-only'];
}
}
// Remove _args if present
delete converted._args;
return converted;
}
export function registerChainCommands(program: Command) {
program
.command('chain [args...]')
.description('Execute multiple commands in sequence with automatic map synchronization\n' +
' Format: chain <cmd1> [opts1] <cmd2> [opts2] ...\n' +
' Examples:\n' +
' • No quotes: chain navigate -u http://example.com click --text Submit screenshot -o result.png\n' +
' • With quotes (when values have spaces): chain click --text "Sign In" fill -s #email -v "user@example.com"\n' +
' • Supports Smart Mode (--text) for click/fill commands\n' +
' • Auto-waits for page load and map generation after navigation\n' +
' • Adds random human-like delay (300-800ms) between commands')
.option('--timeout <ms>', 'Timeout for waiting map ready after navigation (default: 10000ms)', parseInt, 10000)
.option('--delay <ms>', 'Fixed delay between commands in milliseconds (overrides random 300-800ms delay)', parseInt)
.allowUnknownOption()
.action(async (args: string[] = [], options, _cmd) => {
try {
// Extract chain-level options
const chainTimeout = options.timeout || 10000;
const chainDelay = options.delay;
// Use provided args array
const rawArgs = args;
if (rawArgs.length === 0) {
console.error('Error: No commands provided');
console.log('Usage: npm run bp:chain -- <command1> [options1] <command2> [options2] ...');
console.log('Example: npm run bp:chain -- navigate -u "http://example.com" click --text "Submit"');
console.log('Options:');
console.log(' --timeout <ms> Timeout for waiting map ready (default: 10000ms)');
console.log(' --delay <ms> Delay between commands (default: random 300-800ms)');
process.exit(1);
}
// Parse chain arguments
const commands = parseChainArgs(rawArgs);
console.log(`Executing ${commands.length} command(s) in sequence...\n`);
// Execute commands sequentially
for (let i = 0; i < commands.length; i++) {
const { command, args } = commands[i];
console.log(`[${i + 1}/${commands.length}] Executing: ${command} ${args.join(' ')}`);
// Parse command arguments
const params = parseCommandArgs(command, args);
const daemonParams = convertParamsForDaemon(command, params);
// Smart Mode: query map for click/fill commands with text option
if ((command === 'click' || command === 'fill') && daemonParams.text && !daemonParams.selector) {
console.log(`⏳ Waiting for map to be ready (timeout: ${chainTimeout}ms)...`);
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
// Get current URL for debugging
let currentUrl = 'unknown';
try {
const fs = require('fs');
if (fs.existsSync(mapPath)) {
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
currentUrl = mapData.url || 'unknown';
}
} catch (_e) {
// Ignore errors, just use 'unknown'
}
// Try to find selector with retry on failure
let selector = findSelector(
mapPath,
{
text: daemonParams.text as string,
index: daemonParams.index as number | undefined,
type: daemonParams.type as string | undefined,
viewportOnly: daemonParams.viewportOnly as boolean | undefined
},
true, // waitForReady
chainTimeout // timeout
);
// Fallback: regenerate map if element not found
if (!selector) {
selector = await findSelectorWithRetry({
text: daemonParams.text as string,
index: daemonParams.index as number | undefined,
type: daemonParams.type as string | undefined,
viewportOnly: daemonParams.viewportOnly as boolean | undefined
}, 'element');
if (!selector) {
console.error(` in URL: ${currentUrl}`);
process.exit(1);
}
} else {
console.log(`✓ Map ready, found selector: ${selector}`);
}
daemonParams.selector = selector;
delete daemonParams.text;
delete daemonParams.index;
delete daemonParams.type;
delete daemonParams.viewportOnly;
}
// Execute via daemon
const response = await executeViaDaemon(command, daemonParams, { verbose: false });
if (!response.success) {
console.error(`✗ Command failed: ${command}`);
console.error(`Error: ${response.error}`);
process.exit(1);
}
console.log(`✓ Success: ${command}`);
// Wait for map generation to complete after navigation-triggering commands
const navigationCommands = ['navigate', 'click', 'back', 'forward', 'reload'];
if (navigationCommands.includes(command)) {
console.log('⏳ Waiting for interaction map to be ready...');
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
const startTime = Date.now();
const mapTimeout = chainTimeout;
// For navigate command: verify URL matches target
const expectedUrl = command === 'navigate' && daemonParams.url
? String(daemonParams.url)
: null;
// Poll map file until ready: true AND URL matches (for navigate)
while (Date.now() - startTime < mapTimeout) {
try {
const fs = require('fs');
if (fs.existsSync(mapPath)) {
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
// Check if map is ready
if (mapData.ready !== true) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
// For navigate: verify URL matches exactly
if (expectedUrl) {
const mapUrl = String(mapData.url || '');
// Normalize URLs (remove trailing slash, compare as lowercase)
const normalizedExpected = expectedUrl.replace(/\/$/, '').toLowerCase();
const normalizedMap = mapUrl.replace(/\/$/, '').toLowerCase();
// Exact match required
if (normalizedExpected !== normalizedMap) {
// URL mismatch - keep waiting
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
}
// Map is ready and URL matches (if applicable)
console.log(`✓ Interaction map ready (${mapData.statistics?.total || 0} elements)`);
if (expectedUrl) {
console.log(` → URL verified: ${mapData.url}`);
}
break;
}
} catch (_e) {
// Ignore parse errors, continue polling
}
// Wait 100ms before next check
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Display result if present
if (response.data) {
const data = response.data as Record<string, unknown>;
// Display relevant info based on command
if (command === 'navigate' && data.url) {
console.log(` → Navigated to: ${data.url}`);
} else if (command === 'click' && data.selector) {
console.log(` → Clicked: ${data.selector}`);
} else if (command === 'fill' && data.selector) {
console.log(` → Filled: ${data.selector}`);
} else if (command === 'extract' && data.text) {
console.log(` → Extracted: ${data.text}`);
} else if (command === 'screenshot' && data.path) {
console.log(` → Saved to: ${data.path}`);
} else if (command === 'tabs') {
console.log(`${JSON.stringify(data, null, 2)}`);
}
}
console.log();
// Add delay before next command (except for last command)
if (i < commands.length - 1) {
let delayMs: number;
if (chainDelay !== undefined) {
delayMs = chainDelay;
} else {
// Random human-like delay (300-800ms)
delayMs = Math.floor(Math.random() * (800 - 300 + 1)) + 300;
}
console.log(`⏱️ Waiting ${delayMs}ms before next command...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
console.log(`✓ All ${commands.length} command(s) completed successfully`);
process.exit(0);
} catch (error) {
console.error('Chain execution error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,230 @@
import { Command } from 'commander';
import { FormattedConsoleMessage } from '../../cdp/browser';
import { executeViaDaemon } from '../daemon-helper';
import { TIMING } from '../../constants';
import * as fs from 'fs';
import * as path from 'path';
interface ConsoleOptions {
url?: string;
errorsOnly?: boolean;
level?: string;
warnings?: boolean;
logs?: boolean;
limit?: string;
skip?: string;
filter?: string;
exclude?: string;
json?: boolean;
timestamp?: boolean;
noColor?: boolean;
output?: string;
urlFilter?: string;
}
export function registerConsoleCommands(program: Command) {
// Get console messages
program
.command('console')
.description('Retrieve console messages from the page with powerful filtering and formatting options')
.option('-u, --url <url>', 'Navigate to URL before getting console messages')
// Level filtering options
.option('-e, --errors-only', 'Show only error messages', false)
.option('-l, --level <level>', 'Filter by level: error, warning, log, info, verbose')
.option('--warnings', 'Show only warning messages', false)
.option('--logs', 'Show only log messages', false)
// Message limiting options
.option('--limit <number>', 'Maximum number of messages to display')
.option('--skip <number>', 'Skip first N messages')
// Text filtering options
.option('-f, --filter <pattern>', 'Show only messages matching regex pattern')
.option('-x, --exclude <pattern>', 'Exclude messages matching regex pattern')
// Output format options
.option('-j, --json', 'Output in JSON format', false)
.option('-t, --timestamp', 'Show timestamps', false)
.option('--no-color', 'Disable colored output')
// File output
.option('-o, --output <file>', 'Save output to file')
// Source filtering
.option('--url-filter <pattern>', 'Filter by source URL (regex)')
.action(async (options: ConsoleOptions) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
// Wait a bit for console messages to appear
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION));
}
// Get console messages
const response = await executeViaDaemon('console', { errorsOnly: options.errorsOnly });
if (response.success) {
const result = response.data as { count: number; errorCount: number; warningCount: number; logCount: number; messages: FormattedConsoleMessage[] };
// Apply filters
let filteredMessages = filterMessages(result.messages, options);
// Generate output
const output = formatOutput(filteredMessages, result, options);
// Save to file or print to console
if (options.output) {
const outputPath = path.resolve(options.output);
fs.writeFileSync(outputPath, output, 'utf-8');
console.log(`Console messages saved to: ${outputPath}`);
} else {
console.log(output);
}
if (!options.json && !options.output) {
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
}
} else {
console.error('Console retrieval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}
/**
* Filter messages based on provided options
*/
function filterMessages(messages: FormattedConsoleMessage[], options: ConsoleOptions): FormattedConsoleMessage[] {
let filtered = [...messages];
// Level filtering
if (options.level) {
const level = options.level.toLowerCase();
filtered = filtered.filter(msg => msg.level.toLowerCase() === level);
} else if (options.warnings) {
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'warning');
} else if (options.logs) {
filtered = filtered.filter(msg => msg.level.toLowerCase() === 'log');
}
// errorsOnly is handled by daemon
// Text filtering
if (options.filter) {
const regex = new RegExp(options.filter, 'i');
filtered = filtered.filter(msg => regex.test(msg.text));
}
if (options.exclude) {
const regex = new RegExp(options.exclude, 'i');
filtered = filtered.filter(msg => !regex.test(msg.text));
}
// URL filtering
if (options.urlFilter) {
const regex = new RegExp(options.urlFilter, 'i');
filtered = filtered.filter(msg => msg.url && regex.test(msg.url));
}
// Skip messages
const skip = options.skip ? parseInt(options.skip, 10) : 0;
if (skip > 0) {
filtered = filtered.slice(skip);
}
// Limit messages
const limit = options.limit ? parseInt(options.limit, 10) : undefined;
if (limit && limit > 0) {
filtered = filtered.slice(0, limit);
}
return filtered;
}
/**
* Format output based on options
*/
function formatOutput(
messages: FormattedConsoleMessage[],
result: { count: number; errorCount: number; warningCount: number; logCount: number },
options: ConsoleOptions
): string {
if (options.json) {
return JSON.stringify({
total: result.count,
filtered: messages.length,
counts: {
errors: result.errorCount,
warnings: result.warningCount,
logs: result.logCount
},
messages: messages
}, null, 2);
}
// Text format
const lines: string[] = [];
// Header
lines.push(`\n=== Console Messages (Total: ${result.count}, Filtered: ${messages.length}) ===`);
lines.push(`Errors: ${result.errorCount}, Warnings: ${result.warningCount}, Logs: ${result.logCount}\n`);
if (messages.length === 0) {
lines.push('No console messages found.');
} else {
messages.forEach((msg: FormattedConsoleMessage) => {
const parts: string[] = [];
// Timestamp
if (options.timestamp) {
parts.push(`[${msg.timestamp}]`);
}
// Level with color
const levelStr = `[${msg.level.toUpperCase()}]`;
if (options.noColor === false) {
const coloredLevel = colorizeLevel(levelStr, msg.level);
parts.push(coloredLevel);
} else {
parts.push(levelStr);
}
// Location
if (msg.url) {
parts.push(`(${msg.url}:${msg.lineNumber || '?'})`);
}
// Message text
parts.push(msg.text);
lines.push(parts.join(' '));
});
}
return lines.join('\n');
}
/**
* Colorize level string based on message level
*/
function colorizeLevel(levelStr: string, level: string): string {
const colors: Record<string, string> = {
error: '\x1b[31m', // Red
warning: '\x1b[33m', // Yellow
log: '\x1b[36m', // Cyan
info: '\x1b[34m', // Blue
verbose: '\x1b[90m', // Gray
};
const reset = '\x1b[0m';
const color = colors[level.toLowerCase()] || '';
return `${color}${levelStr}${reset}`;
}

View File

@@ -0,0 +1,92 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerCookiesCommands(program: Command) {
// Get cookies command
program
.command('cookies')
.description('Retrieve all cookies from the current page (or navigate to a URL first with -u)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
// Try to connect to existing browser first, launch new one if failed
try {
await browser.connect();
} catch {
await browser.launch();
}
// Only navigate if URL is provided
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getCookies(browser);
console.log(`Found ${result.count} cookies:`);
console.log(JSON.stringify(result.cookies, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Set cookie
program
.command('set-cookie')
.description('Set a cookie with specified name and value (supports domain, path, secure, and httpOnly options)')
.requiredOption('-n, --name <name>', 'Cookie name')
.requiredOption('-v, --value <value>', 'Cookie value')
.option('-d, --domain <domain>', 'Cookie domain')
.option('-p, --path <path>', 'Cookie path', '/')
.option('--secure', 'Secure cookie', false)
.option('--http-only', 'HTTP only cookie', false)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.setCookie(
browser,
options.name,
options.value,
options.domain,
options.path,
options.secure,
options.httpOnly
);
console.log('Cookie set:', result.cookie);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Delete cookies
program
.command('delete-cookies')
.description('Delete cookies by name (deletes all cookies if no name is specified with -n)')
.option('-n, --name <name>', 'Cookie name to delete (deletes all if not specified)')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.deleteCookies(browser, options.name);
console.log(result.message);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,137 @@
/**
* Daemon management commands
*/
import { Command } from 'commander';
import { DaemonManager } from '../../daemon/manager';
export function registerDaemonCommands(program: Command) {
// Start daemon
program
.command('daemon-start')
.description('Start Browser Pilot daemon (persistent background browser)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.start({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Stop daemon
program
.command('daemon-stop')
.description('Stop Browser Pilot daemon and close browser')
.option('-q, --quiet', 'Suppress output')
.option('-f, --force', 'Force kill the daemon')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.stop({ verbose: !options.quiet, force: options.force });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Restart daemon
program
.command('daemon-restart')
.description('Restart Browser Pilot daemon')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.restart({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Daemon status
program
.command('daemon-status')
.description('Check daemon status and browser info')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const state = await manager.getStatus({ verbose: !options.quiet });
process.exit(state ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Query interaction map
program
.command('daemon-query-map')
.description('Query interaction map by text, type, or ID')
.option('-t, --text <text>', 'Search by text content')
.option('-T, --type <type>', 'Filter by element type (button, link, input, etc)')
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
.option('--id <id>', 'Direct ID lookup')
.option('-v, --viewport-only', 'Only visible elements in viewport')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const params: Record<string, unknown> = {};
if (options.text) params.text = options.text;
if (options.type) params.type = options.type;
if (options.index) params.index = options.index;
if (options.id) params.id = options.id;
if (options.viewportOnly) params.viewportOnly = true;
await manager.queryMap(params, { verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Generate interaction map
program
.command('daemon-generate-map')
.description('Generate interaction map for current page (use -f to force)')
.option('-f, --force', 'Force regeneration (ignore cache)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
const params: Record<string, unknown> = {};
if (options.force) params.force = true;
await manager.generateMap(params, { verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get map status
program
.command('daemon-map-status')
.description('Get interaction map status (URL, element count, cache)')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
const manager = new DaemonManager();
try {
await manager.getMapStatus({ verbose: !options.quiet });
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,160 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { executeViaDaemon } from '../daemon-helper';
export function registerDataCommands(program: Command) {
// Extract text command
program
.command('extract')
.description('Extract text from element (use -s for selector)')
.option('-u, --url <url>', 'URL to extract from (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector (optional)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
// Try to connect to existing browser first, launch new one if failed
try {
await browser.connect();
} catch {
await browser.launch();
}
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.extractText(browser, options.selector);
console.log('Extracted text:', result.text);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Evaluate command
program
.command('eval')
.description('Execute JavaScript on page (requires -e/--expression)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-e, --expression <script>', 'JavaScript expression to evaluate')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
// Navigate if URL provided
if (options.url) {
await executeViaDaemon('navigate', { url: options.url });
}
// Execute JavaScript
const response = await executeViaDaemon('eval', { expression: options.expression });
if (response.success) {
const data = response.data as { result: unknown };
console.log('Result:', data.result);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Eval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get content command
program
.command('content')
.description('Get page HTML content')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.getContent(browser);
console.log('HTML content length:', result.length);
console.log(result.content);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Extract data
program
.command('extract-data')
.description('Extract data using multiple selectors (requires -s/--selectors)')
.requiredOption('-s, --selectors <json>', 'JSON object of key-selector pairs')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const selectors = JSON.parse(options.selectors);
const result = await actions.extractData(browser, selectors);
console.log('Extracted data:', JSON.stringify(result.data, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Find element
program
.command('find')
.description('Find element and return info (requires -s/--selector)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.findElement(browser, options.selector);
console.log('Element info:', JSON.stringify(result.element, null, 2));
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get element property
program
.command('get-property')
.description('Get element property value (requires -s and -p)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.requiredOption('-p, --property <property>', 'Property name')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.getElementProperty(browser, options.selector, options.property);
console.log(`${options.property}:`, result.value);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,29 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerDialogsCommands(program: Command) {
// Dialog response command
program
.command('dialog')
.description('Respond to JavaScript dialogs (alert/confirm/prompt) by accepting, dismissing, or entering text for prompts')
.option('-a, --accept', 'Accept dialog (default: true)', true)
.option('-d, --dismiss', 'Dismiss dialog')
.option('-t, --text <text>', 'Text for prompt dialog')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const accept = !options.dismiss;
const result = await actions.respondToDialog(browser, accept, options.text);
console.log('Dialog', result.accept ? 'accepted' : 'dismissed');
if (result.promptText) {
console.log('Prompt text:', result.promptText);
}
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,28 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerEmulationCommands(program: Command) {
// Emulate media command
program
.command('emulate-media')
.description('Emulate media type (screen/print) or color scheme (light/dark/no-preference) for testing responsive designs and dark mode')
.option('-m, --media <type>', 'Media type: screen or print')
.option('-c, --color-scheme <scheme>', 'Color scheme: light, dark, or no-preference')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.emulateMedia(
browser,
options.media as 'screen' | 'print' | undefined,
options.colorScheme as 'light' | 'dark' | 'no-preference' | undefined
);
console.log('Emulated media:', result.mediaType || 'none', 'colorScheme:', result.colorScheme || 'none');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,53 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerFocusCommands(program: Command) {
// Focus element
program
.command('focus')
.description('Set focus on a specific element (for keyboard input)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.focus(browser, options.selector);
console.log('Focused:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Blur element
program
.command('blur')
.description('Remove focus from an element (deactivate active element)')
.requiredOption('-s, --selector <selector>', 'CSS selector')
.option('-u, --url <url>', 'Navigate to URL first')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.blur(browser, options.selector);
console.log('Blurred:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,81 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerFormsCommands(program: Command) {
// Select option command
program
.command('select')
.description('Select option from dropdown (requires -s and -v)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of select element')
.requiredOption('-v, --value <value>', 'Option value to select')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.selectOption(browser, options.selector, options.value);
console.log('Selected:', result.value, 'in', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Check checkbox command
program
.command('check')
.description('Check a checkbox (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.check(browser, options.selector);
console.log('Checked:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Uncheck checkbox command
program
.command('uncheck')
.description('Uncheck a checkbox (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of checkbox')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
const browser = new ChromeBrowser(options.headless);
try {
try { await browser.connect(); } catch { await browser.launch(); }
if (options.url) {
await actions.navigate(browser, options.url);
await actions.waitForLoad(browser);
}
const result = await actions.uncheck(browser, options.selector);
console.log('Unchecked:', result.selector);
console.log('Browser will stay open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,243 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
import { findSelectorWithRetry } from './selector-helper';
import { getOutputDir } from '../../cdp/config';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import * as path from 'path';
export function registerInteractionCommands(program: Command) {
// Click command
program
.command('click')
.description('Click an element (use -s for selector or --text for smart mode)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector to click (direct mode)')
.option('--text <text>', 'Text content to search for (smart mode)')
.option('--index <number>', 'Select nth match (1-based, for duplicate text)', parseInt)
.option('--type <type>', 'Element type filter (e.g., button, input)')
.option('--viewport-only', 'Only search visible elements', false)
.option('--verify', 'Verify action success', false)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
let selector = options.selector;
// Smart mode: query map if text option provided
if (options.text && !selector) {
console.log(`🔍 Searching for: "${options.text}"${options.index ? ` (match #${options.index})` : ''}`);
console.log(`📁 Map path: ${path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME)}`);
selector = await findSelectorWithRetry({
text: options.text,
index: options.index,
type: options.type,
viewportOnly: options.viewportOnly
}, 'element');
if (!selector) {
console.error(' Try using --selector for direct mode');
process.exit(1);
}
console.log(`✓ Found element with selector: ${selector}`);
}
// Validate selector
if (!selector) {
console.error('❌ No selector provided. Use either:');
console.error(' --selector <selector> (direct mode)');
console.error(' --text <text> (smart mode)');
process.exit(1);
}
const response = await executeViaDaemon('click', {
selector,
verify: options.verify
});
if (response.success) {
console.log('✓ Clicked:', selector);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Click failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Fill command
program
.command('fill')
.description('Fill an input field (requires -v/--value)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.option('-s, --selector <selector>', 'CSS selector of input field (direct mode)')
.option('--label <label>', 'Label or placeholder text to search for (smart mode)')
.option('--type <type>', 'Input type filter (e.g., input-text, input-password)', 'input')
.option('--viewport-only', 'Only search visible elements', false)
.requiredOption('-v, --value <value>', 'Value to fill')
.option('--verify', 'Verify action success', false)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
let selector = options.selector;
// Smart mode: query map if label option provided
if (options.label && !selector) {
console.log(`🔍 Searching for input: "${options.label}"`);
selector = await findSelectorWithRetry({
text: options.label,
type: options.type,
viewportOnly: options.viewportOnly
}, 'input field');
if (!selector) {
console.error(' Try using --selector for direct mode');
process.exit(1);
}
console.log(`✓ Found input field with selector: ${selector}`);
}
// Validate selector
if (!selector) {
console.error('❌ No selector provided. Use either:');
console.error(' --selector <selector> (direct mode)');
console.error(' --label <label> (smart mode)');
process.exit(1);
}
const response = await executeViaDaemon('fill', {
selector,
value: options.value,
verify: options.verify
});
if (response.success) {
console.log('✓ Filled:', selector, 'with:', options.value);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Fill failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Hover command
program
.command('hover')
.description('Hover over an element (requires -s/--selector)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector to hover')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
if (options.url) {
await executeViaDaemon('navigate', { url: options.url, waitForLoad: true });
}
const response = await executeViaDaemon('hover', { selector: options.selector });
if (response.success) {
console.log('✓ Hovered over:', options.selector);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Hover failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Press key command
program
.command('press')
.description('Press a keyboard key (requires -k/--key, e.g., Enter, Tab, Escape)')
.requiredOption('-k, --key <key>', 'Key to press (e.g., Enter, Tab, Escape)')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('press', { key: options.key });
if (response.success) {
console.log('✓ Pressed key:', options.key);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Press failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Type text command
program
.command('type')
.description('Type text character by character (requires -t/--text)')
.requiredOption('-t, --text <text>', 'Text to type')
.option('-d, --delay <ms>', 'Delay between characters (ms)', parseInt, 0)
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('type', {
text: options.text,
delay: options.delay
});
if (response.success) {
console.log('✓ Typed text:', options.text);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('❌ Type failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Upload file command (not implemented in server yet, skip for now)
program
.command('upload')
.description('Upload file to input element (requires -s/--selector and -f/--file)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('-s, --selector <selector>', 'CSS selector of file input')
.requiredOption('-f, --file <path>', 'File path to upload')
.option('--headless', 'Run in headless mode', false)
.action(async () => {
console.error('Upload command not yet implemented in daemon mode');
process.exit(1);
});
// Drag and drop command (not implemented in server yet, skip for now)
program
.command('drag')
.description('Drag and drop element (requires --from and --to selectors)')
.option('-u, --url <url>', 'URL to navigate to (optional, uses current page if not specified)')
.requiredOption('--from <selector>', 'Source element selector')
.requiredOption('--to <selector>', 'Target element selector')
.option('--headless', 'Run in headless mode', false)
.action(async () => {
console.error('Drag command not yet implemented in daemon mode');
process.exit(1);
});
}

View File

@@ -0,0 +1,104 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerNavigationCommands(program: Command) {
// Navigate command
program
.command('navigate')
.description('Navigate to a URL (requires -u/--url)')
.requiredOption('-u, --url <url>', 'URL to navigate to')
.option('--headless', 'Run in headless mode', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('navigate', { url: options.url });
if (response.success) {
console.log('Navigated to:', options.url);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Go back command
program
.command('back')
.description('Navigate back in browser history')
.action(async () => {
try {
const response = await executeViaDaemon('back', {});
if (response.success) {
const data = response.data as { success: boolean; url?: string; error?: string };
if (data.success) {
console.log('Navigated back to:', data.url);
} else {
console.log(data.error);
}
} else {
console.error('Back navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Go forward command
program
.command('forward')
.description('Navigate forward in history')
.action(async () => {
try {
const response = await executeViaDaemon('forward', {});
if (response.success) {
const data = response.data as { success: boolean; url?: string; error?: string };
if (data.success) {
console.log('Navigated forward to:', data.url);
} else {
console.log(data.error);
}
} else {
console.error('Forward navigation failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Reload command
program
.command('reload')
.description('Reload the current page')
.option('--hard', 'Hard reload (ignore cache)', false)
.action(async (options) => {
try {
const response = await executeViaDaemon('reload', { hard: options.hard });
if (response.success) {
const data = response.data as { success: boolean; hardReload: boolean };
console.log('Page reloaded (hard:', data.hardReload, ')');
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Reload failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,77 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerNetworkCommands(program: Command) {
// Block URL command
program
.command('block-url')
.description('Block network requests matching a URL pattern (e.g., "*.jpg", "*ads*", "*analytics*")')
.requiredOption('-p, --pattern <pattern>', 'URL pattern to block (e.g., "*.jpg", "*ads*")')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.blockRequest(browser, options.pattern);
console.log('Blocked URL pattern:', result.urlPattern);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Unblock URLs command
program
.command('unblock-urls')
.description('Remove all network request blocks and allow all URLs to load')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
await actions.unblockRequests(browser);
console.log('All URL blocks removed');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Enable request interception
program
.command('enable-interception')
.description('Enable network request interception for monitoring and modifying HTTP requests')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.enableRequestInterception(browser);
console.log('Request interception enabled');
console.log('Note:', result.note);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Disable request interception
program
.command('disable-interception')
.description('Disable network request interception and return to normal browsing mode')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
await actions.disableRequestInterception(browser);
console.log('Request interception disabled');
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,199 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerQueryCommands(program: Command) {
// Query interaction map
program
.command('query')
.description('Search interaction map for elements (by text, type, tag, or ID with pagination support)')
.option('-t, --text <text>', 'Search by text content')
.option('--type <type>', 'Filter by element type (supports aliases: "input" → "input-*")')
.option('--tag <tag>', 'Filter by HTML tag (e.g., "input", "button")')
.option('-i, --index <number>', 'Select nth match (1-based)', parseInt)
.option('--viewport-only', 'Only search visible elements in viewport', false)
.option('--id <id>', 'Direct element ID lookup')
.option('--list-types', 'List all element types with counts')
.option('--list-texts', 'List all text contents')
.option('--limit <number>', 'Maximum results to return (default: 20, 0=unlimited)', parseInt)
.option('--offset <number>', 'Number of results to skip (default: 0)', parseInt)
.option('--verbose', 'Include detailed information', false)
.action(async (options) => {
try {
// Build query parameters
const params: Record<string, unknown> = {};
if (options.text) params.text = options.text;
if (options.type) params.type = options.type;
if (options.tag) params.tag = options.tag;
if (options.index) params.index = options.index;
if (options.viewportOnly) params.viewportOnly = true;
if (options.id) params.id = options.id;
if (options.listTypes) params.listTypes = true;
if (options.listTexts) params.listTexts = true;
if (options.limit !== undefined) params.limit = options.limit;
if (options.offset !== undefined) params.offset = options.offset;
if (options.verbose) params.verbose = true;
// Execute query
const response = await executeViaDaemon('query-map', params);
if (response.success) {
const result = response.data as {
count: number;
results: Array<{
selector: string;
alternatives: string[];
element: {
tag: string;
text: string | undefined;
position: { x: number; y: number };
};
}>;
types?: Record<string, number>;
texts?: Array<{ text: string; type: string; count: number }>;
total?: number;
};
// Handle listTypes output
if (options.listTypes && result.types) {
console.log(`\n=== Element Types ===`);
const sortedTypes = Object.entries(result.types).sort((a, b) => b[1] - a[1]);
sortedTypes.forEach(([type, count]) => {
console.log(`${type}: ${count}`);
});
console.log(`\nTotal: ${result.total} elements`);
}
// Handle listTexts output
else if (options.listTexts && result.texts) {
const limit = options.limit !== undefined ? options.limit : 20;
const showingText = limit === 0 ? 'all' : `${result.count}/${result.total}`;
console.log(`\n=== Text Contents (showing ${showingText}) ===\n`);
result.texts.forEach((item, idx) => {
const num = (options.offset || 0) + idx + 1;
const textPreview = item.text.length > 50 ? item.text.substring(0, 50) + '...' : item.text;
console.log(`${num}. "${textPreview}" (${item.type}${item.count > 1 ? `, ${item.count} matches` : ''})`);
});
if (limit > 0 && result.total && result.total > result.count + (options.offset || 0)) {
console.log(`\nUse --limit to see more, or --type to filter`);
}
}
// Handle regular query output
else {
const showingText = result.total ? `${result.count}/${result.total}` : `${result.count}`;
console.log(`\n=== Query Results (${showingText}) ===`);
if (result.count === 0) {
console.log('\nNo elements found matching your query.');
} else {
result.results.forEach((item, idx) => {
const num = (options.offset || 0) + idx + 1;
console.log(`\n[${num}]`);
if (!options.verbose) {
// Compact format
const textPreview = item.element.text ?
(item.element.text.length > 50 ? item.element.text.substring(0, 50) + '...' : item.element.text) : '';
console.log(` <${item.element.tag}> ${textPreview ? `"${textPreview}"` : '(no text)'}`);
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
console.log(` Selector: ${item.selector}`);
} else {
// Verbose format
console.log(` Tag: <${item.element.tag}>`);
if (item.element.text) {
const text = item.element.text.substring(0, 100);
console.log(` Text: "${text}${item.element.text.length > 100 ? '...' : ''}"`);
}
console.log(` Position: (${item.element.position.x}, ${item.element.position.y})`);
console.log(` 📍 Best Selector: ${item.selector}`);
if (item.alternatives.length > 0 && item.alternatives.length <= 3) {
console.log(` Alternatives:`);
item.alternatives.forEach(alt => console.log(` - ${alt}`));
}
}
});
if (result.total && result.total > result.count + (options.offset || 0)) {
console.log(`\nUse --limit and --offset for pagination, or --verbose for details`);
}
}
}
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Query failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Get map status
program
.command('map-status')
.description('Check interaction map status (URL, element count, cache validity, timestamp)')
.action(async () => {
try {
const response = await executeViaDaemon('get-map-status', {});
if (response.success) {
const status = response.data as {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
};
console.log(`\n=== Interaction Map Status ===`);
console.log(`Map exists: ${status.exists ? 'Yes' : 'No'}`);
if (status.exists) {
console.log(`URL: ${status.url || 'Unknown'}`);
console.log(`Timestamp: ${status.timestamp || 'Unknown'}`);
console.log(`Element count: ${status.elementCount}`);
console.log(`Cache valid: ${status.cacheValid ? 'Yes (< 10 minutes)' : 'No (expired or not cached)'}`);
}
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Status retrieval failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Force regenerate map
program
.command('regen-map')
.description('Force rebuild interaction map (use when page content changes or map is stale)')
.action(async () => {
try {
console.log('Regenerating interaction map...');
const response = await executeViaDaemon('generate-map', { force: true });
if (response.success) {
const map = response.data as { url: string; timestamp: string; elementCount: number };
console.log(`✓ Map regenerated successfully`);
console.log(` URL: ${map.url}`);
console.log(` Timestamp: ${map.timestamp}`);
console.log(` Total elements: ${map.elementCount}`);
console.log('\nBrowser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Map regeneration failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,34 @@
import { Command } from 'commander';
import { executeViaDaemon } from '../daemon-helper';
export function registerScrollCommands(program: Command) {
// Scroll command
program
.command('scroll')
.description('Scroll the page or a specific element to coordinates (x, y) or use a CSS selector to scroll an element into view')
.requiredOption('-x, --x <pixels>', 'Horizontal scroll position', parseInt)
.requiredOption('-y, --y <pixels>', 'Vertical scroll position', parseInt)
.option('-s, --selector <selector>', 'CSS selector to scroll (optional)')
.action(async (options) => {
try {
const response = await executeViaDaemon('scroll', {
x: options.x,
y: options.y,
selector: options.selector
});
if (response.success) {
const data = response.data as { success: boolean; position: { x: number; y: number } };
console.log('Scrolled to:', data.position);
console.log('Browser will stay open. Use "daemon-stop" to close it.');
} else {
console.error('Scroll failed:', response.error);
}
process.exit(response.success ? 0 : 1);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,71 @@
/**
* Selector helper utilities with automatic map regeneration fallback
*/
import { findSelector } from '../../cdp/map/query-map';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { getOutputDir } from '../../cdp/config';
import { executeViaDaemon } from '../daemon-helper';
import * as path from 'path';
export interface SelectorQueryParams {
text: string;
index?: number;
type?: string;
viewportOnly?: boolean;
}
/**
* Find selector with automatic map regeneration fallback
*
* This function queries the interaction map for an element matching the given criteria.
* If the element is not found, it automatically regenerates the map and retries once.
*
* @param params - Selector query parameters (text, index, type, viewportOnly)
* @param elementType - Type of element being searched (for logging, e.g., "element", "input field")
* @returns Selector string or null if not found after retry
*/
export async function findSelectorWithRetry(
params: SelectorQueryParams,
elementType: string = 'element'
): Promise<string | null> {
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
// First attempt
let selector = findSelector(mapPath, params);
// Fallback: regenerate map if element not found
if (!selector) {
console.log(`⚠️ ${elementType.charAt(0).toUpperCase() + elementType.slice(1)} not found in map, regenerating map and retrying...`);
try {
// Execute generate-map via daemon (force: true to regenerate)
const regenResponse = await executeViaDaemon('generate-map', { force: true }, { verbose: false });
if (!regenResponse.success) {
console.error(`✗ Failed to regenerate map: ${regenResponse.error}`);
return null;
}
console.log(`🔄 Map regenerated, retrying selector search...`);
// Allow file system to flush (especially important on Windows)
await new Promise(resolve => setTimeout(resolve, 300));
// Retry finding selector
selector = findSelector(mapPath, params);
if (!selector) {
console.error(`${elementType.charAt(0).toUpperCase() + elementType.slice(1)} still not found after map regeneration`);
return null;
}
console.log(`✓ Found ${elementType} after map regeneration: ${selector}`);
} catch (error) {
console.error(`✗ Error during map regeneration: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
return selector;
}

View File

@@ -0,0 +1,222 @@
/**
* System maintenance commands
*/
import { Command } from 'commander';
import { DaemonManager } from '../../daemon/manager';
import { loadSharedConfig, saveSharedConfig } from '../../cdp/config';
import { getLocalTimestamp } from '../../utils/timestamp';
import { logger } from '../../utils/logger';
import * as fs from 'fs';
import * as path from 'path';
/**
* Show usage error and exit
*/
function showUsageAndExit(message: string, usage: string): never {
console.error(`❌ Error: ${message}`);
console.error(`Usage: ${usage}`);
process.exit(1);
}
/**
* Validate project root path
*/
function validateProjectRoot(projectRoot: string): string {
// Normalize path to prevent path traversal attacks
const normalized = path.normalize(projectRoot);
// Check if path exists
if (!fs.existsSync(normalized)) {
throw new Error(`Project root does not exist: ${normalized}`);
}
// Check if it's a directory
const stats = fs.statSync(normalized);
if (!stats.isDirectory()) {
throw new Error(`Project root is not a directory: ${normalized}`);
}
return normalized;
}
/**
* Parse and validate port number
*/
function parsePort(value: string): number {
const port = parseInt(value, 10);
if (isNaN(port)) {
throw new Error('Port must be a valid number');
}
return port;
}
export function registerSystemCommands(program: Command) {
// Reinstall command
program
.command('reinstall')
.description('Reinstall Browser Pilot scripts (removes .browser-pilot directory)')
.option('-y, --yes', 'Skip confirmation prompt')
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
try {
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const browserPilotDir = path.join(projectRoot, '.browser-pilot');
// Check if directory exists
if (!fs.existsSync(browserPilotDir)) {
if (!options.quiet) {
console.log('✨ .browser-pilot directory not found. Nothing to reinstall.');
console.log('Run any command to initialize Browser Pilot.');
}
process.exit(0);
return;
}
// Confirmation prompt (skip if --yes flag provided)
if (!options.yes) {
console.log('⚠️ This will remove the .browser-pilot directory and stop the daemon.');
console.log('📁 Directory: ' + browserPilotDir);
console.log('');
console.log('Next command will trigger automatic reinstallation.');
console.log('');
console.log('Use --yes flag to skip this prompt.');
process.exit(1);
return;
}
// Stop daemon if running
const manager = new DaemonManager();
if (await manager.isRunning()) {
if (!options.quiet) {
console.log('🛑 Stopping daemon...');
}
try {
await manager.stop({ verbose: false, force: true });
if (!options.quiet) {
console.log('✓ Daemon stopped');
}
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
console.error('⚠️ Failed to stop daemon:', errorMessage);
console.error('Continuing anyway, but manual cleanup may be needed.');
if (stopError instanceof Error && stopError.stack) {
logger.debug(`Stack trace: ${stopError.stack}`);
}
}
} else {
if (!options.quiet) {
console.log('✓ Daemon not running');
}
}
// Remove .browser-pilot directory
if (!options.quiet) {
console.log('🗑️ Removing .browser-pilot directory...');
}
fs.rmSync(browserPilotDir, { recursive: true, force: true });
if (!options.quiet) {
console.log('✨ Browser Pilot reinstalled successfully!');
console.log('');
console.log('Run any command to initialize Browser Pilot:');
console.log(' node .browser-pilot/bp navigate -u "https://example.com"');
console.log('');
console.log('Note: The .browser-pilot directory will be recreated automatically.');
}
process.exit(0);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Error during reinstall:', errorMessage);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}
process.exit(1);
}
});
// Change port command
program
.command('change-port')
.description('Change Chrome DevTools Protocol port for current project')
.option('-p, --port <number>', 'New port number', parsePort)
.option('-q, --quiet', 'Suppress output')
.action(async (options) => {
try {
const projectRoot = validateProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const projectName = path.basename(projectRoot);
if (!options.port) {
showUsageAndExit('Port number is required', 'change-port -p <port>');
}
const newPort = options.port;
// Validate port number
if (isNaN(newPort) || newPort < 1024 || newPort > 65535) {
console.error('❌ Error: Invalid port number');
console.error('Port must be between 1024 and 65535');
process.exit(1);
return;
}
// Load config
const config = loadSharedConfig();
const projectConfig = config.projects[projectName];
if (!projectConfig) {
console.error('❌ Error: Project configuration not found');
console.error('Run any Browser Pilot command to initialize the project first');
process.exit(1);
return;
}
const oldPort = projectConfig.port;
// Stop daemon if running
const manager = new DaemonManager();
if (await manager.isRunning()) {
if (!options.quiet) {
console.log('🛑 Stopping daemon on port ' + oldPort + '...');
}
try {
await manager.stop({ verbose: false, force: true });
if (!options.quiet) {
console.log('✓ Daemon stopped');
}
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
console.error('⚠️ Failed to stop daemon:', errorMessage);
console.error('Continuing anyway, but daemon may still be running on old port.');
if (stopError instanceof Error && stopError.stack) {
logger.debug(`Stack trace: ${stopError.stack}`);
}
}
}
// Update port in config
projectConfig.port = newPort;
projectConfig.lastUsed = getLocalTimestamp();
saveSharedConfig(config);
if (!options.quiet) {
console.log('✅ Port changed successfully!');
console.log('');
console.log('Old port: ' + oldPort);
console.log('New port: ' + newPort);
console.log('');
console.log('The daemon will use the new port on next command.');
}
process.exit(0);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Error changing port:', errorMessage);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}
process.exit(1);
}
});
}

View File

@@ -0,0 +1,118 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
import { DaemonManager } from '../../daemon/manager';
export function registerTabsCommands(program: Command) {
// List tabs command
program
.command('tabs')
.description('List all open tabs with their index numbers, titles, and URLs')
.action(async () => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.listTabs(browser);
const tabs = result.tabs as Array<{ index: number; title: string; url: string; targetId: string }>;
console.log(`Found ${result.count} tabs:`);
tabs.forEach((tab) => {
console.log(`[${tab.index}] ${tab.title} - ${tab.url}`);
});
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// New tab command
program
.command('new-tab')
.description('Open a new tab in the browser (optionally navigate to a specific URL with -u)')
.option('-u, --url <url>', 'URL to open', 'about:blank')
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.newTab(browser, options.url);
console.log('New tab opened:', result.targetId);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Close tab command
program
.command('close-tab')
.description('Close a specific tab by its index number (use "tabs" command to see index numbers)')
.requiredOption('-i, --index <number>', 'Tab index to close', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.closeTab(browser, undefined, options.index);
console.log(result.message);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Switch tab
program
.command('switch-tab')
.description('Switch to a different tab by its index number (use "tabs" command to see index numbers)')
.requiredOption('-i, --index <index>', 'Tab index', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.switchTab(browser, options.index);
console.log(result.message);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Close browser command
program
.command('close')
.description('Close the browser completely and stop the daemon process')
.action(async () => {
const browser = new ChromeBrowser(false);
const daemonManager = new DaemonManager();
try {
// Close browser first
await browser.connect();
await browser.close();
console.log('✓ Browser closed');
// Then stop daemon
if (await daemonManager.isRunning()) {
await daemonManager.stop({ verbose: true });
console.log('✓ Daemon stopped');
}
process.exit(0);
} catch (error) {
// Try to stop daemon even if browser close failed
try {
if (await daemonManager.isRunning()) {
await daemonManager.stop({ verbose: true });
console.log('✓ Daemon stopped');
}
} catch (daemonError) {
console.error('Warning: Could not stop daemon:', daemonError);
}
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,62 @@
import { Command } from 'commander';
import { ChromeBrowser } from '../../cdp/browser';
import * as actions from '../../cdp/actions';
export function registerWaitCommands(program: Command) {
// Wait for element command
program
.command('wait')
.description('Wait for a specific element to appear in the DOM using a CSS selector with optional timeout')
.requiredOption('-s, --selector <selector>', 'CSS selector to wait for')
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 30000)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitFor(browser, options.selector, options.timeout);
console.log('Element found:', result.selector);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Wait milliseconds
program
.command('sleep')
.description('Pause execution for a specified duration in milliseconds (useful for waiting between actions or for animations to complete)')
.requiredOption('-t, --time <ms>', 'Milliseconds to wait', parseInt)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitMilliseconds(browser, options.time);
console.log(`Waited ${result.waitedMs}ms`);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
// Wait for network idle
program
.command('wait-idle')
.description('Wait for all network requests to complete and the page to become idle (useful after navigation or dynamic content loading)')
.option('-t, --timeout <ms>', 'Timeout in milliseconds', parseInt, 5000)
.action(async (options) => {
const browser = new ChromeBrowser(false);
try {
await browser.connect();
const result = await actions.waitForNetworkIdle(browser, options.timeout);
console.log('Network is idle:', result.state);
console.log('Browser remains open. Use "close" command to close it.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,68 @@
/**
* Helper functions for daemon-based CLI commands
*/
import { IPCClient } from '../daemon/client';
import { DaemonManager } from '../daemon/manager';
import { IPCResponse } from '../daemon/protocol';
import { TIMING } from '../constants';
/**
* Execute command via daemon (auto-start if needed)
*/
export async function executeViaDaemon(
command: string,
params: Record<string, unknown> = {},
options: {
timeout?: number;
verbose?: boolean;
autoStart?: boolean;
} = {}
): Promise<IPCResponse> {
const { timeout = TIMING.WAIT_FOR_NAVIGATION, verbose = true, autoStart = true } = options;
// Ensure daemon is running
if (autoStart) {
const manager = new DaemonManager();
// If navigate command and daemon not running, pass initial URL
const isRunning = await manager.isRunning();
const initialUrl = (command === 'navigate' && !isRunning && params.url)
? params.url as string
: undefined;
await manager.ensureRunning({ verbose, initialUrl });
}
// Send command to daemon
const client = new IPCClient();
try {
const response = await client.sendRequest(command, params, timeout);
return response;
} finally {
client.close();
}
}
/**
* Format and display command result
*/
export function displayResult(response: IPCResponse, verbose: boolean = true): void {
if (!verbose) return;
if (response.success) {
// Display success result based on data type
const data = response.data;
if (data && typeof data === 'object') {
// Pretty print objects
console.log(JSON.stringify(data, null, 2));
} else if (data !== undefined) {
// Print primitive values
console.log(data);
}
} else {
console.error('Error:', response.error);
}
}

View File

@@ -0,0 +1,208 @@
/**
* Browser Pilot Constants
* 모든 매직 넘버, URL, 타이밍 등을 중앙에서 관리
*/
/**
* Chrome DevTools Protocol 관련 상수
* @property DEFAULT_PORT - 기본 디버깅 포트 (9222)
* @property PORT_RANGE_MAX - 포트 검색 범위 (100)
* @property LOCALHOST - 로컬 호스트 주소
* @property WS_TIMEOUT - WebSocket 연결 타임아웃 (30초)
* @property NAVIGATION_TIMEOUT - 페이지 네비게이션 타임아웃 (30초)
* @property EVALUATION_TIMEOUT - 스크립트 실행 타임아웃 (10초)
*/
export const CDP = {
DEFAULT_PORT: 9222,
PORT_RANGE_MAX: 100,
LOCALHOST: '127.0.0.1',
WS_TIMEOUT: 30000, // 30 seconds
NAVIGATION_TIMEOUT: 30000, // 30 seconds
EVALUATION_TIMEOUT: 10000, // 10 seconds
} as const;
/**
* 파일 시스템 관련 상수
* @property OUTPUT_DIR - 출력 디렉토리 (.browser-pilot)
* @property SCREENSHOTS_DIR - 스크린샷 디렉토리 (screenshots)
* @property PDFS_DIR - PDF 디렉토리 (pdfs)
* @property INTERACTION_MAP_FILE - Interaction Map 파일명
* @property MAP_CACHE_FILE - Map 캐시 파일명
* @property DAEMON_PID_FILE - 데몬 PID 파일명
* @property DAEMON_SOCKET - 데몬 소켓 파일명
* @property GITIGNORE_CONTENT - .gitignore 기본 내용
*/
export const FS = {
OUTPUT_DIR: '.browser-pilot',
SCREENSHOTS_DIR: 'screenshots',
PDFS_DIR: 'pdfs',
INTERACTION_MAP_FILE: 'interaction-map.json',
MAP_CACHE_FILE: 'map-cache.json',
DAEMON_PID_FILE: 'daemon.pid',
DAEMON_SOCKET: 'daemon.sock',
GITIGNORE_CONTENT: `# Browser Pilot generated files
*
`,
} as const;
/**
* 타이밍 관련 상수 (모든 시간 단위는 밀리초)
* @property DEFAULT_WAIT_TIMEOUT - 기본 대기 타임아웃 (30초)
* @property NETWORK_IDLE_TIMEOUT - 네트워크 idle 체크 간격 (500ms)
* @property MAP_CACHE_TTL - Map 캐시 유효 기간 (10분)
* @property DAEMON_IDLE_TIMEOUT - 데몬 idle 타임아웃 (30분)
* @property DAEMON_PING_INTERVAL - 데몬 ping 간격 (5초)
* @property SCREENSHOT_DELAY - 스크린샷 딜레이 (100ms)
* @property HOOK_INPUT_TIMEOUT - Hook stdin 읽기 타임아웃 (100ms)
* @property ACTION_DELAY_SHORT - 짧은 액션 딜레이 (50ms)
* @property ACTION_DELAY_MEDIUM - 표준 액션 딜레이 (100ms)
* @property ACTION_DELAY_LONG - 긴 액션 딜레이 (500ms)
* @property ACTION_DELAY_NAVIGATION - 네비게이션/페이지 로드 딜레이 (1초)
* @property POLLING_INTERVAL_FAST - 빠른 폴링 간격 (100ms)
* @property POLLING_INTERVAL_STANDARD - 표준 폴링 간격 (500ms)
* @property POLLING_INTERVAL_SLOW - 느린 폴링 간격 (1초)
* @property WAIT_FOR_ELEMENT - 엘리먼트 대기 타임아웃 (5초)
* @property WAIT_FOR_NAVIGATION - 네비게이션 대기 타임아웃 (30초)
* @property WAIT_FOR_LOAD_STATE - 로드 상태 대기 타임아웃 (30초)
* @property RECENT_MESSAGE_WINDOW - 최근 에러/경고 감지 윈도우 (5초)
*/
export const TIMING = {
DEFAULT_WAIT_TIMEOUT: 30000, // 30 seconds
NETWORK_IDLE_TIMEOUT: 500, // 500ms
MAP_CACHE_TTL: 600000, // 10 minutes
DAEMON_IDLE_TIMEOUT: 1800000, // 30 minutes
DAEMON_PING_INTERVAL: 5000, // 5 seconds
SCREENSHOT_DELAY: 100, // 100ms
HOOK_INPUT_TIMEOUT: 100, // 100ms for reading stdin
// Action delays
ACTION_DELAY_SHORT: 50, // 50ms - very short delay
ACTION_DELAY_MEDIUM: 100, // 100ms - standard action delay
ACTION_DELAY_LONG: 500, // 500ms - longer action delay
ACTION_DELAY_NAVIGATION: 1000, // 1s - navigation/page load delay
// Polling intervals
POLLING_INTERVAL_FAST: 100, // 100ms - fast polling
POLLING_INTERVAL_STANDARD: 500, // 500ms - standard polling
POLLING_INTERVAL_SLOW: 1000, // 1s - slow polling
// Wait timeouts
WAIT_FOR_ELEMENT: 5000, // 5s - wait for element
WAIT_FOR_NAVIGATION: 30000, // 30s - wait for navigation
WAIT_FOR_LOAD_STATE: 30000, // 30s - wait for load state
// Message/Error detection windows
RECENT_MESSAGE_WINDOW: 5000, // 5s - recent error/warning detection window
} as const;
/**
* 시간 단위 변환 상수
* @property MS_PER_SECOND - 1초당 밀리초 (1000)
* @property MS_PER_MINUTE - 1분당 밀리초 (60000)
* @property MS_PER_HOUR - 1시간당 밀리초 (3600000)
*/
export const TIME_CONVERSION = {
MS_PER_SECOND: 1000,
MS_PER_MINUTE: 60000,
MS_PER_HOUR: 3600000,
} as const;
// HTTP Status
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
} as const;
// Screenshot
export const SCREENSHOT = {
DEFAULT_FORMAT: 'png' as const,
DEFAULT_QUALITY: 80,
FULL_PAGE: true,
} as const;
// PDF
export const PDF = {
DEFAULT_FORMAT: 'A4' as const,
DEFAULT_MARGIN: {
top: '1cm',
right: '1cm',
bottom: '1cm',
left: '1cm',
},
PRINT_BACKGROUND: true,
} as const;
// Interaction Map
export const INTERACTION_MAP = {
CACHE_TTL: 600000, // 10 minutes
MAX_ELEMENTS: 10000,
SELECTOR_PRIORITY: ['byId', 'byText', 'byCSS', 'byRole', 'byAriaLabel'] as const,
} as const;
// Daemon
export const DAEMON = {
IPC_TIMEOUT: 5000, // 5 seconds
MAX_RETRIES: 3,
RETRY_DELAY: 1000, // 1 second
IDLE_CHECK_INTERVAL: 60000, // 1 minute
} as const;
// Browser
export const BROWSER = {
USER_AGENT_OVERRIDE: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
DEFAULT_VIEWPORT: {
width: 1920,
height: 1080,
},
HEADLESS: false,
} as const;
/**
* 환경 변수 이름 상수
* @property CDP_DEBUG_PORT - Chrome 디버깅 포트 환경 변수명
* @property CLAUDE_PROJECT_DIR - Claude 프로젝트 디렉토리 환경 변수명
* @property CLAUDE_PLUGIN_ROOT - Claude 플러그인 루트 환경 변수명
*/
export const ENV = {
CDP_DEBUG_PORT: 'CDP_DEBUG_PORT',
CLAUDE_PROJECT_DIR: 'CLAUDE_PROJECT_DIR',
CLAUDE_PLUGIN_ROOT: 'CLAUDE_PLUGIN_ROOT',
} as const;
// Error Messages
export const ERROR_MESSAGES = {
PROJECT_ROOT_NOT_FOUND: '[browser-pilot] Could not determine project root',
ELEMENT_NOT_FOUND: 'Element not found',
TIMEOUT: 'Operation timed out',
NAVIGATION_FAILED: 'Navigation failed',
DAEMON_NOT_RUNNING: 'Daemon is not running',
DAEMON_START_FAILED: 'Failed to start daemon',
PORT_NOT_AVAILABLE: 'No available port found',
CONFIG_LOAD_FAILED: 'Failed to load configuration',
INVALID_SELECTOR: 'Invalid selector',
} as const;
// Success Messages
export const SUCCESS_MESSAGES = {
NAVIGATION_COMPLETE: 'Navigation complete',
ELEMENT_CLICKED: 'Element clicked',
FORM_FILLED: 'Form filled',
SCREENSHOT_SAVED: 'Screenshot saved',
PDF_GENERATED: 'PDF generated',
DAEMON_STARTED: 'Daemon started',
DAEMON_STOPPED: 'Daemon stopped',
} as const;
// Regex Patterns
export const PATTERNS = {
XPATH: /^\/\//,
CSS_ID: /^#[a-zA-Z0-9_-]+$/,
CSS_CLASS: /^\.[a-zA-Z0-9_-]+$/,
URL: /^https?:\/\//,
} as const;

View File

@@ -0,0 +1,212 @@
/**
* IPC Client for Browser Pilot Daemon
* Used by CLI commands to communicate with the daemon
*/
import { Socket, connect } from 'net';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { getOutputDir } from '../cdp/config';
import {
IPCRequest,
IPCResponse,
IPCError,
IPCErrorCodes,
SOCKET_PATH_PREFIX,
DEFAULT_TIMEOUT,
getProjectSocketName
} from './protocol';
import { logger } from '../utils/logger';
export class IPCClient {
private socket: Socket | null = null;
private socketPath: string;
private pendingRequests: Map<string, {
resolve: (response: IPCResponse) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
private buffer: string = '';
constructor() {
const outputDir = getOutputDir();
this.socketPath = this.getSocketPath(outputDir);
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(outputDir: string): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Connect to daemon
*/
async connect(): Promise<void> {
if (this.socket && !this.socket.destroyed) {
return; // Already connected
}
// Check if socket file exists (Unix only)
if (process.platform !== 'win32' && !existsSync(this.socketPath)) {
throw new IPCError('Daemon not running (socket file not found)', IPCErrorCodes.DAEMON_NOT_RUNNING);
}
return new Promise((resolve, reject) => {
this.socket = connect(this.socketPath);
this.socket.on('connect', () => {
this.setupSocket();
resolve();
});
this.socket.on('error', (error) => {
reject(new IPCError(`Connection failed: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
});
});
}
/**
* Setup socket event handlers
*/
private setupSocket(): void {
if (!this.socket) return;
this.socket.on('data', (data) => {
this.buffer += data.toString();
// Process complete JSON messages (delimited by newline)
const messages = this.buffer.split('\n');
this.buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue;
try {
const response: IPCResponse = JSON.parse(message);
this.handleResponse(response);
} catch (error) {
logger.error('Failed to parse response', error);
}
}
});
this.socket.on('error', (error) => {
logger.error('Socket error', error);
this.rejectAllPending(new IPCError(`Socket error: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
});
this.socket.on('close', () => {
this.socket = null;
this.rejectAllPending(new IPCError('Connection closed', IPCErrorCodes.CONNECTION_ERROR));
});
}
/**
* Handle response from daemon
*/
private handleResponse(response: IPCResponse): void {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
logger.warn(`Received response for unknown request: ${response.id}`);
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(response.id);
if (response.success) {
pending.resolve(response);
} else {
pending.reject(new IPCError(response.error || 'Command failed', IPCErrorCodes.COMMAND_FAILED));
}
}
/**
* Reject all pending requests
*/
private rejectAllPending(error: Error): void {
for (const [_id, pending] of this.pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
this.pendingRequests.clear();
}
/**
* Send request to daemon
*/
async sendRequest(command: string, params: Record<string, unknown> = {}, timeout: number = DEFAULT_TIMEOUT): Promise<IPCResponse> {
await this.connect();
if (!this.socket || this.socket.destroyed) {
throw new IPCError('Not connected to daemon', IPCErrorCodes.CONNECTION_ERROR);
}
const requestId = randomUUID();
const request: IPCRequest = {
id: requestId,
command,
params,
timeout
};
return new Promise((resolve, reject) => {
// Set up timeout
const timeoutHandle = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new IPCError(`Request timeout after ${timeout}ms`, IPCErrorCodes.TIMEOUT));
}, timeout);
// Store pending request
this.pendingRequests.set(requestId, {
resolve,
reject,
timeout: timeoutHandle
});
// Send request
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.socket!.write(JSON.stringify(request) + '\n', (error) => {
if (error) {
clearTimeout(timeoutHandle);
this.pendingRequests.delete(requestId);
reject(new IPCError(`Failed to send request: ${error.message}`, IPCErrorCodes.CONNECTION_ERROR));
}
});
});
}
/**
* Close connection
*/
close(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.rejectAllPending(new IPCError('Client closed', IPCErrorCodes.CONNECTION_ERROR));
}
/**
* Check if daemon is running
*/
static isDaemonRunning(): boolean {
const outputDir = getOutputDir();
const socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\${SOCKET_PATH_PREFIX}`
: join(outputDir, `${SOCKET_PATH_PREFIX}.sock`);
return existsSync(socketPath);
}
}

View File

@@ -0,0 +1,83 @@
/**
* Capture command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
/**
* Handle screenshot command
*/
export async function handleScreenshot(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const filename = params.filename as string | undefined;
const fullPage = params.fullPage !== false; // Default true
// Parse clip options if provided
let clip: actions.ClipOptions | undefined;
if (params.clipX !== undefined && params.clipY !== undefined &&
params.clipWidth !== undefined && params.clipHeight !== undefined) {
clip = {
x: params.clipX as number,
y: params.clipY as number,
width: params.clipWidth as number,
height: params.clipHeight as number,
scale: params.clipScale as number | undefined
};
}
return actions.screenshot(context.browser, filename || 'screenshot.png', fullPage, clip);
}
/**
* Handle set viewport size command
*/
export async function handleSetViewport(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const width = params.width as number;
const height = params.height as number;
const deviceScaleFactor = (params.deviceScaleFactor as number) || 1;
const mobile = (params.mobile as boolean) || false;
if (!width || !height) {
throw new Error('Width and height are required for viewport');
}
return actions.setViewportSize(context.browser, width, height, deviceScaleFactor, mobile);
}
/**
* Handle get viewport command
*/
export async function handleGetViewport(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
return actions.getViewport(context.browser);
}
/**
* Handle get screen info command
*/
export async function handleGetScreenInfo(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
return actions.getScreenInfo(context.browser);
}
/**
* Handle PDF generation command
*/
export async function handlePdf(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const filename = params.filename as string | undefined;
const landscape = params.landscape as boolean | undefined;
return actions.generatePdf(context.browser, filename || 'page.pdf', landscape || false);
}

View File

@@ -0,0 +1,56 @@
/**
* Data extraction command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
/**
* Handle extract command (text extraction)
*/
export async function handleExtract(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string | undefined;
// If selectors object provided, use extractData for multiple selectors
if (params.selectors && typeof params.selectors === 'object') {
return actions.extractData(context.browser, params.selectors as Record<string, string>);
}
// Otherwise use extractText for single selector
return actions.extractText(context.browser, selector);
}
/**
* Handle content command (get page HTML)
*/
export async function handleContent(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
return actions.getContent(context.browser);
}
/**
* Handle find command (find element)
*/
export async function handleFind(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string;
return actions.findElement(context.browser, selector);
}
/**
* Handle JavaScript evaluation command
*/
export async function handleEval(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const expression = params.expression as string;
return actions.evaluate(context.browser, expression);
}

View File

@@ -0,0 +1,55 @@
/**
* Unified exports for all Browser Pilot Daemon handlers
*/
// Export handler context type
export type { HandlerContext } from './navigation-handlers';
// Navigation handlers
export {
handleNavigate,
handleBack,
handleForward,
handleReload
} from './navigation-handlers';
// Interaction handlers
export {
handleClick,
handleFill,
handleHover,
handlePress,
handleType
} from './interaction-handlers';
// Capture handlers
export {
handleScreenshot,
handlePdf,
handleSetViewport,
handleGetViewport,
handleGetScreenInfo
} from './capture-handlers';
// Data handlers
export {
handleExtract,
handleContent,
handleFind,
handleEval
} from './data-handlers';
// Map handlers
export {
handleQueryMap,
handleGenerateMap,
handleGetMapStatus
} from './map-handlers';
// Utility handlers
export {
handleScroll,
handleWait,
handleConsole,
handleStatus
} from './utility-handlers';

View File

@@ -0,0 +1,293 @@
/**
* Interaction command handlers for Browser Pilot Daemon
*/
import { ChromeBrowser } from '../../cdp/browser';
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
/**
* Page change tracker for monitoring action effects
*/
interface PageChangeTracker {
urlBefore: string;
urlAfter: string | null;
navigationDetected: boolean;
domChangeDetected: boolean;
networkActive: boolean;
}
/**
* Selector query parameters for Smart Mode
*/
interface SelectorQueryParams {
text: string;
index?: number;
type?: string;
tag?: string;
viewportOnly?: boolean;
}
/**
* Helper: Get current URL from browser
*/
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
try {
const result = await browser.sendCommand<{ result: { value: string } }>(
'Runtime.evaluate',
{ expression: 'window.location.href', returnByValue: true }
);
return result.result?.value || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Helper: Execute action with automatic state tracking
*/
async function executeActionWithTracking<T>(
browser: ChromeBrowser,
actionFn: () => Promise<T>
): Promise<{ result: T; tracker: PageChangeTracker }> {
// Capture state before action
const urlBefore = await getCurrentUrl(browser);
const pageChangeTracker: PageChangeTracker = {
urlBefore,
urlAfter: null,
navigationDetected: false,
domChangeDetected: false,
networkActive: false
};
try {
// Execute action
const result = await actionFn();
// Capture state after action
const urlAfter = await getCurrentUrl(browser);
pageChangeTracker.urlAfter = urlAfter;
pageChangeTracker.navigationDetected = urlBefore !== urlAfter;
return { result, tracker: pageChangeTracker };
} finally {
// Cleanup if needed
}
}
/**
* Helper: Find selector with 3-stage fallback logic
* Stage 1: Type-based search (with alias expansion)
* Stage 2: Tag-based search
* Stage 3: Map regeneration + retry (max 3 attempts)
*/
async function findSelectorWithRetry(
context: HandlerContext,
params: SelectorQueryParams
): Promise<string> {
const { findSelector } = await import('../../cdp/map/query-map');
const { SELECTOR_RETRY_CONFIG } = await import('../../cdp/actions/helpers');
const { getOutputDir } = await import('../../cdp/config');
const path = await import('path');
const mapPath = path.join(getOutputDir(), SELECTOR_RETRY_CONFIG.MAP_FILENAME);
logger.debug(`🔍 Smart Mode: querying map for text="${params.text}"`);
let foundSelector: string | null = null;
let attemptCount = 0;
const maxAttempts = 3;
const originalType = params.type;
while (!foundSelector && attemptCount < maxAttempts) {
attemptCount++;
// Stage 1: Try with type (with alias expansion)
if (params.type && !params.tag) {
logger.debug(`[Attempt ${attemptCount}] Type-based search: "${params.type}"`);
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
type: params.type,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector with type search: ${foundSelector}`);
break;
}
// Stage 2: Fallback to tag-based search
if (originalType) {
const baseTag = originalType.split('-')[0];
logger.debug(`[Attempt ${attemptCount}] Type failed, trying tag: "${baseTag}"`);
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
tag: baseTag,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector with tag search: ${foundSelector}`);
break;
}
}
} else {
// No type specified, just search
foundSelector = findSelector(mapPath, {
text: params.text,
index: params.index,
type: params.type,
tag: params.tag,
viewportOnly: params.viewportOnly
});
if (foundSelector) {
logger.debug(`✓ Found selector: ${foundSelector}`);
break;
}
}
// Stage 3: Regenerate map and retry
if (!foundSelector && context.mapManager && attemptCount < maxAttempts) {
logger.warn(`[Attempt ${attemptCount}] Element not found, regenerating map...`);
await context.mapManager.generateMap(context.browser, true);
logger.debug('🔄 Map regenerated, retrying...');
}
}
// Final check
if (!foundSelector) {
let errorMsg = `Element not found after ${attemptCount} attempt(s): "${params.text}"\n`;
errorMsg += '\n💡 Troubleshooting:\n';
errorMsg += `- Check text is exact: --text "${params.text}"\n`;
if (params.type) {
const baseTag = params.type.split('-')[0];
errorMsg += `- Try tag search: --tag ${baseTag}\n`;
}
errorMsg += `- List available elements: node .browser-pilot/bp query --list-texts\n`;
errorMsg += `- Remove filters: try searching without --type or --viewport-only\n`;
logger.error(`${errorMsg}`);
throw new Error(errorMsg);
}
return foundSelector;
}
/**
* Handle click command with smart mode support
*/
export async function handleClick(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
let selector = params.selector as string | undefined;
// Smart Mode: if text provided, query map
if (params.text && !selector) {
selector = await findSelectorWithRetry(context, {
text: params.text as string,
index: params.index as number | undefined,
type: params.type as string | undefined,
tag: params.tag as string | undefined,
viewportOnly: params.viewportOnly as boolean | undefined
});
}
if (!selector) {
throw new Error('No selector provided');
}
// Execute with tracking
const { result, tracker } = await executeActionWithTracking(
context.browser,
() => actions.click(context.browser, selector)
);
// Always regenerate map after click (DOM may have changed, URL may or may not change)
logger.debug(`🔄 Regenerating map after click (URL: ${tracker.urlBefore}${tracker.urlAfter})`);
if (context.mapManager) {
await context.mapManager.generateMapSerially(context.browser, false);
}
return result;
}
/**
* Handle fill command with smart mode support
*/
export async function handleFill(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
let selector = params.selector as string | undefined;
const value = params.value as string;
// Smart Mode: if text provided, query map
if (params.text && !selector) {
selector = await findSelectorWithRetry(context, {
text: params.text as string,
index: params.index as number | undefined,
type: params.type as string | undefined,
tag: params.tag as string | undefined,
viewportOnly: params.viewportOnly as boolean | undefined
});
}
if (!selector) {
throw new Error('No selector provided');
}
// Execute with tracking
const { result, tracker } = await executeActionWithTracking(
context.browser,
() => actions.fill(context.browser, selector, value)
);
// Always regenerate map after fill (DOM may have changed, URL may or may not change)
logger.debug(`🔄 Regenerating map after fill (URL: ${tracker.urlBefore}${tracker.urlAfter})`);
if (context.mapManager) {
await context.mapManager.generateMapSerially(context.browser, false);
}
return result;
}
/**
* Handle hover command
*/
export async function handleHover(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const selector = params.selector as string;
return actions.hover(context.browser, selector);
}
/**
* Handle press (keyboard key) command
*/
export async function handlePress(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const key = params.key as string;
return actions.pressKey(context.browser, key);
}
/**
* Handle type (text input) command
*/
export async function handleType(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const text = params.text as string;
const delay = params.delay as number | undefined;
return actions.typeText(context.browser, text, delay);
}

View File

@@ -0,0 +1,227 @@
/**
* Interaction Map command handlers for Browser Pilot Daemon
*/
import { join } from 'path';
import { HandlerContext, saveLastUrl } from './navigation-handlers';
import { loadMap, queryMap, listTypes, listTexts } from '../../cdp/map/query-map';
import { SELECTOR_RETRY_CONFIG } from '../../cdp/actions/helpers';
import { logger } from '../../utils/logger';
import {
MapQueryParams,
MapQueryResult,
MapGenerateParams,
MapGenerateResult,
MapStatusResult
} from '../protocol';
/**
* Handle query-map command with 3-stage fallback logic
*/
export async function handleQueryMap(
context: HandlerContext,
params: Record<string, unknown>
): Promise<MapQueryResult> {
const queryParams = params as MapQueryParams;
// Load map
const mapPath = join(context.outputDir, SELECTOR_RETRY_CONFIG.MAP_FILENAME);
let currentMap = loadMap(mapPath);
// Handle listTypes request
if (queryParams.listTypes) {
const types = listTypes(currentMap);
return {
count: Object.keys(types).length,
results: [],
types,
total: currentMap.statistics.total
};
}
// Handle listTexts request
if (queryParams.listTexts) {
const texts = listTexts(currentMap, {
type: queryParams.type,
limit: queryParams.limit,
offset: queryParams.offset
});
return {
count: texts.length,
results: [],
texts,
total: Object.keys(currentMap.indexes.byText).length
};
}
// 3-stage fallback logic (max 3 attempts)
let results: ReturnType<typeof queryMap> = [];
let attemptCount = 0;
const maxAttempts = 3;
const originalType = queryParams.type;
while (results.length === 0 && attemptCount < maxAttempts) {
attemptCount++;
// Stage 1: Try with type (with alias expansion)
if (queryParams.type && !queryParams.tag) {
logger.debug(`[Attempt ${attemptCount}] Trying type-based search: "${queryParams.type}"`);
results = queryMap(currentMap, queryParams);
if (results.length > 0) {
logger.debug(`✓ Found ${results.length} element(s) with type search`);
break;
}
// Stage 2: Fallback to tag-based search
// Extract base tag from type (e.g., "input-search" → "input")
if (originalType) {
const baseTag = originalType.split('-')[0];
logger.debug(`[Attempt ${attemptCount}] Type search failed, trying tag-based search: "${baseTag}"`);
const tagParams = { ...queryParams, type: undefined, tag: baseTag };
results = queryMap(currentMap, tagParams);
if (results.length > 0) {
logger.debug(`✓ Found ${results.length} element(s) with tag search`);
break;
}
}
} else {
// No type specified, just query
results = queryMap(currentMap, queryParams);
if (results.length > 0) {
break;
}
}
// Stage 3: Regenerate map and retry
if (results.length === 0 && context.mapManager && attemptCount < maxAttempts) {
logger.warn(`[Attempt ${attemptCount}] No elements found, regenerating map and retrying...`);
await context.mapManager.generateMap(context.browser, true);
logger.debug('🔄 Map regenerated, reloading and retrying...');
// Wait for map to be ready before continuing
currentMap = loadMap(mapPath, true, 10000);
}
}
// Calculate total count only once at the end
const allResults = queryMap(currentMap, { ...queryParams, limit: 0 });
// Final check: no results found after all attempts
if (results.length === 0 && !queryParams.listTypes && !queryParams.listTexts) {
// Build detailed error message with edge case handling
let errorMsg = 'No elements found matching query criteria after ' + attemptCount + ' attempt(s).\n';
errorMsg += '\n💡 Troubleshooting tips:\n';
if (queryParams.text) {
errorMsg += `- Try searching without quotes: --text ${queryParams.text.replace(/"/g, '')}\n`;
errorMsg += `- Try partial text: --text "${queryParams.text.substring(0, Math.min(10, queryParams.text.length))}"\n`;
errorMsg += `- List all texts: node .browser-pilot/bp query --list-texts\n`;
}
if (queryParams.type) {
const baseTag = queryParams.type.split('-')[0];
errorMsg += `- Try tag-based search: --tag ${baseTag}\n`;
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
errorMsg += `- Remove type filter and search by text only\n`;
}
if (queryParams.tag) {
errorMsg += `- Try type-based search: --type ${queryParams.tag}\n`;
errorMsg += `- List available types: node .browser-pilot/bp query --list-types\n`;
}
if (!queryParams.text && !queryParams.type && !queryParams.tag) {
errorMsg += `- Specify search criteria: --text, --type, or --tag\n`;
errorMsg += `- List all elements: node .browser-pilot/bp query --list-types\n`;
}
errorMsg += `- Force map regeneration: node .browser-pilot/bp regen-map\n`;
errorMsg += `- Check if element is in viewport: --viewport-only (or remove if used)\n`;
throw new Error(errorMsg);
}
// Return all results in MapQueryResult format
return {
count: results.length,
results: results.map(result => ({
selector: result.selector,
alternatives: result.alternatives,
element: {
tag: result.element.tag,
text: result.element.text,
position: result.element.position
}
})),
total: allResults.length
};
}
/**
* Handle generate-map command
*/
export async function handleGenerateMap(
context: HandlerContext,
params: Record<string, unknown>
): Promise<MapGenerateResult> {
if (!context.mapManager) {
throw new Error('MapManager not initialized');
}
const generateParams = params as MapGenerateParams;
const force = generateParams.force ?? false;
// Get current URL before generation
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const currentUrl = urlResult.result?.value || 'unknown';
// Check if we can use cache
const cached = !force && context.mapManager.isCacheValid(currentUrl);
// Generate map
const map = await context.mapManager.generateMap(context.browser, force);
// Save last visited URL
if (currentUrl !== 'unknown') {
await saveLastUrl(context.outputDir, currentUrl);
}
return {
success: true,
url: map.url,
elementCount: map.statistics.total,
timestamp: map.timestamp,
cached
};
}
/**
* Handle get-map-status command
*/
export async function handleGetMapStatus(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<MapStatusResult> {
if (!context.mapManager) {
throw new Error('MapManager not initialized');
}
// Get current URL
const urlResult = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const currentUrl = urlResult.result?.value || 'unknown';
// Get map status
return context.mapManager.getMapStatus(currentUrl);
}

View File

@@ -0,0 +1,184 @@
/**
* Navigation command handlers for Browser Pilot Daemon
*/
import * as fs from 'fs';
import * as path from 'path';
import { ChromeBrowser } from '../../cdp/browser';
import { MapManager } from '../map-manager';
import * as actions from '../../cdp/actions';
import { logger } from '../../utils/logger';
/**
* Handler context containing dependencies
*/
export interface HandlerContext {
browser: ChromeBrowser;
mapManager?: MapManager;
outputDir: string;
}
/**
* Helper: Get current URL from browser
*/
async function getCurrentUrl(browser: ChromeBrowser): Promise<string> {
try {
const result = await browser.sendCommand<{ result: { value: string } }>(
'Runtime.evaluate',
{ expression: 'window.location.href', returnByValue: true }
);
return result.result?.value || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Helper: Save last visited URL to file
*/
export async function saveLastUrl(outputDir: string, url: string): Promise<void> {
try {
const lastUrlPath = path.join(outputDir, 'last-url.txt');
await fs.promises.writeFile(lastUrlPath, url, 'utf-8');
logger.debug(`💾 Saved last URL: ${url}`);
} catch (error) {
logger.warn(`Failed to save last URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Helper: Load last visited URL from file
*/
export async function loadLastUrl(outputDir: string): Promise<string | null> {
try {
const lastUrlPath = path.join(outputDir, 'last-url.txt');
const url = await fs.promises.readFile(lastUrlPath, 'utf-8');
const trimmedUrl = url.trim();
logger.debug(`📂 Loaded last URL: ${trimmedUrl}`);
return trimmedUrl || null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// File not found is an expected case, no need to log a warning
return null;
}
logger.warn(`Failed to load last URL: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Helper: Wait for map to be ready for a specific URL
*/
async function waitForMapReady(
context: HandlerContext,
expectedUrl: string,
_timeout: number
): Promise<void> {
logger.debug(`⏳ Waiting for map generation (URL: ${expectedUrl})...`);
if (!context.mapManager) {
logger.warn('MapManager not available, skipping map generation');
return;
}
// Check if map exists and has correct URL
const mapStatus = await context.mapManager.getMapStatus(expectedUrl);
if (!mapStatus.exists || mapStatus.url !== expectedUrl) {
// Map doesn't exist or has wrong URL - generate new map
logger.debug(`🔨 Generating new map for: ${expectedUrl}`);
await context.mapManager.generateMapSerially(context.browser, false);
// Above await completes only when map generation is fully done
}
logger.debug(`✅ Map ready for: ${expectedUrl}`);
}
/**
* Handle navigate command
*/
export async function handleNavigate(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const url = params.url as string;
const result = await actions.navigate(context.browser, url);
// Navigation always changes URL, wait for map
logger.info(`🔄 Navigating to: ${url}`);
await waitForMapReady(context, url, 10000);
// Save last visited URL
await saveLastUrl(context.outputDir, url);
return result;
}
/**
* Handle back command
*/
export async function handleBack(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
const result = await actions.goBack(context.browser);
// Get new URL after navigation
const newUrl = await getCurrentUrl(context.browser);
logger.info(`🔄 Navigated back to: ${newUrl}`);
await waitForMapReady(context, newUrl, 10000);
// Save last visited URL
if (newUrl !== 'unknown') {
await saveLastUrl(context.outputDir, newUrl);
}
return result;
}
/**
* Handle forward command
*/
export async function handleForward(
context: HandlerContext,
_params: Record<string, unknown>
): Promise<unknown> {
const result = await actions.goForward(context.browser);
// Get new URL after navigation
const newUrl = await getCurrentUrl(context.browser);
logger.info(`🔄 Navigated forward to: ${newUrl}`);
await waitForMapReady(context, newUrl, 10000);
// Save last visited URL
if (newUrl !== 'unknown') {
await saveLastUrl(context.outputDir, newUrl);
}
return result;
}
/**
* Handle reload command
*/
export async function handleReload(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const hard = params.hard as boolean | undefined;
// Get current URL before reload
const currentUrl = await getCurrentUrl(context.browser);
const result = await actions.reload(context.browser, hard || false);
// Reload stays on same URL, wait for map
logger.info(`🔄 Reloading page: ${currentUrl}`);
await waitForMapReady(context, currentUrl, 10000);
// Save last visited URL
if (currentUrl !== 'unknown') {
await saveLastUrl(context.outputDir, currentUrl);
}
return result;
}

View File

@@ -0,0 +1,79 @@
/**
* Utility command handlers for Browser Pilot Daemon
*/
import { HandlerContext } from './navigation-handlers';
import * as actions from '../../cdp/actions';
import { DaemonState } from '../protocol';
/**
* Handle scroll command
*/
export async function handleScroll(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const x = params.x as number;
const y = params.y as number;
return actions.scroll(context.browser, { x, y });
}
/**
* Handle wait command
*/
export async function handleWait(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const duration = params.duration as number | undefined;
if (duration) {
// Simple sleep implementation
await new Promise(resolve => setTimeout(resolve, duration));
return { success: true, duration };
} else {
return actions.waitForLoad(context.browser);
}
}
/**
* Handle console command
*/
export async function handleConsole(
context: HandlerContext,
params: Record<string, unknown>
): Promise<unknown> {
const errorsOnly = params.errorsOnly as boolean | undefined;
const result = await actions.getConsoleMessages(context.browser, errorsOnly);
if (params.clear) {
context.browser.clearConsoleMessages();
}
return result;
}
/**
* Handle status command
*/
export async function handleStatus(
context: HandlerContext,
_params: Record<string, unknown>,
startTime: number,
lastActivity: number
): Promise<DaemonState> {
const currentUrl = await context.browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
return {
connected: true,
currentUrl: currentUrl.result?.value || null,
targetId: null, // CDP client doesn't expose targetId directly
debugPort: context.browser.debugPort,
consoleMessageCount: context.browser.getConsoleMessages().length,
networkErrorCount: context.browser.getNetworkErrors().length,
uptime: Date.now() - startTime,
lastActivity: lastActivity
};
}

View File

@@ -0,0 +1,596 @@
/**
* Daemon Process Manager
* Handles starting, stopping, and checking status of the Browser Pilot Daemon
*/
import { spawn, ChildProcess, execSync } from 'child_process';
import { join, basename } from 'path';
import { existsSync, unlinkSync } from 'fs';
import { readFile } from 'fs/promises';
import * as net from 'net';
import { getOutputDir, loadSharedConfig, saveSharedConfig } from '../cdp/config';
import { IPCClient } from './client';
import {
PID_FILENAME,
SOCKET_PATH_PREFIX,
DaemonState,
MapQueryParams,
MapQueryResult,
MapGenerateParams,
MapGenerateResult,
MapStatusResult,
getProjectSocketName
} from './protocol';
import { logger } from '../utils/logger';
import { getLocalTimestamp } from '../utils/timestamp';
import { TIMING, DAEMON } from '../constants';
export class DaemonManager {
private outputDir: string;
private pidPath: string;
private socketPath: string;
private cachedPid: { pid: number | null; timestamp: number } | null = null;
private readonly PID_CACHE_TTL = 1000; // 1 second
constructor() {
this.outputDir = getOutputDir();
this.pidPath = join(this.outputDir, PID_FILENAME);
this.socketPath = this.getSocketPath();
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Start daemon process with retry and port fallback
*/
async start(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
const { verbose = true, initialUrl } = options;
// Check if already running
if (await this.isRunning()) {
if (verbose) {
console.log('✓ Daemon is already running');
}
return;
}
if (verbose) {
console.log('🚀 Starting Browser Pilot Daemon...');
}
// Get path to server.js (compiled output)
const serverPath = join(__dirname, 'server.js');
if (!existsSync(serverPath)) {
throw new Error(`Daemon server not found at ${serverPath}. Did you run 'npm run build'?`);
}
// Try starting with retry logic
const maxRetries = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Prepare environment variables
const env = { ...process.env };
if (initialUrl) {
env.BP_INITIAL_URL = initialUrl;
if (verbose && attempt === 1) {
logger.info(`Setting initial URL: ${initialUrl}`);
}
}
// Spawn daemon as detached process
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
detached: true,
stdio: 'ignore', // Don't inherit stdio
cwd: process.cwd(),
env // Pass environment variables
});
// Detach the process so it continues running when parent exits
daemon.unref();
// Wait a bit for daemon to start
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon started successfully');
}
return; // Success!
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (verbose) {
console.log(`⚠️ Attempt ${attempt}/${maxRetries} failed: ${lastError.message}`);
}
// Stop any partially started daemon
if (await this.isRunning()) {
if (verbose) {
console.log('🛑 Stopping partially started daemon...');
}
try {
await this.stop({ verbose: false, force: true });
} catch (stopError) {
const errorMessage = stopError instanceof Error ? stopError.message : String(stopError);
logger.warn(`Failed to stop partially started daemon: ${errorMessage}`);
// Continue to next retry
}
}
// On last retry, try changing port
if (attempt === maxRetries) {
if (verbose) {
console.log('🔄 Attempting automatic port change...');
}
try {
await this.changePortAutomatically(verbose);
// One more attempt with new port
if (verbose) {
console.log('🚀 Retrying with new port...');
}
const env = { ...process.env };
if (initialUrl) {
env.BP_INITIAL_URL = initialUrl;
}
const daemon: ChildProcess = spawn(process.execPath, [serverPath], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
env
});
daemon.unref();
await this.waitForDaemon(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon started successfully with new port');
}
return; // Success with new port!
} catch (portChangeError) {
if (verbose) {
console.log(`⚠️ Port change also failed: ${(portChangeError as Error).message}`);
}
}
}
// Wait a bit before retrying
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
// All retries failed
throw new Error(`Failed to start daemon after ${maxRetries} attempts. Last error: ${lastError?.message || 'Unknown'}`);
}
/**
* Change port automatically to find available port
*/
private async changePortAutomatically(verbose: boolean): Promise<void> {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const projectName = basename(projectRoot);
const config = loadSharedConfig();
const projectConfig = config.projects[projectName];
if (!projectConfig) {
throw new Error('Project configuration not found');
}
const oldPort = projectConfig.port;
const newPort = await this.findAvailablePort(oldPort);
if (verbose) {
console.log(`📍 Changing port: ${oldPort}${newPort}`);
}
projectConfig.port = newPort;
projectConfig.lastUsed = getLocalTimestamp();
saveSharedConfig(config);
}
/**
* Find available port starting from base + 1
*/
private async findAvailablePort(basePort: number): Promise<number> {
const MAX_PORTS = 100;
const timeout = 10000; // 10 seconds total timeout
const startTime = Date.now();
for (let port = basePort + 1; port < basePort + MAX_PORTS; port++) {
if (Date.now() - startTime > timeout) {
throw new Error('Timeout while searching for available port');
}
if (await this.isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available ports found in range ${basePort + 1}-${basePort + MAX_PORTS}`);
}
/**
* Check if port is available
*/
private async isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
try {
server.close();
} catch (error) {
// Ignore close errors
}
}
};
const timeout = setTimeout(() => {
cleanup();
resolve(false); // Timeout = not available
}, 2000);
server.once('error', () => {
clearTimeout(timeout);
cleanup();
resolve(false);
});
server.once('listening', () => {
clearTimeout(timeout);
cleanup();
resolve(true);
});
server.listen(port, '127.0.0.1');
});
}
/**
* Stop daemon process
*/
async stop(options: { verbose?: boolean; force?: boolean } = {}): Promise<void> {
const { verbose = true, force = false } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('⚠️ Daemon is not running');
}
return;
}
if (verbose) {
console.log('🛑 Stopping Browser Pilot Daemon...');
}
try {
// Try graceful shutdown via IPC first
if (!force) {
const client = new IPCClient();
await client.sendRequest('shutdown', {}, DAEMON.IPC_TIMEOUT);
client.close();
// Wait for daemon to stop
await this.waitForStop(DAEMON.IPC_TIMEOUT);
if (verbose) {
console.log('✓ Daemon stopped gracefully');
}
return;
}
} catch (_error) {
if (verbose) {
logger.warn('Graceful shutdown failed, forcing...');
}
}
// Force kill if graceful shutdown failed
const pid = await this.getPid();
if (pid) {
try {
process.kill(pid, 'SIGTERM');
// Wait a bit
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_SLOW));
// Check if still running
try {
process.kill(pid, 0);
// Still running, force kill
process.kill(pid, 'SIGKILL');
} catch (_error) {
// Process is gone, good
}
if (verbose) {
console.log('✓ Daemon stopped (forced)');
}
} catch (_error) {
// Process already gone
if (verbose) {
console.log('✓ Daemon stopped');
}
}
// Clean up PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
}
// Clean up socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
}
}
}
/**
* Restart daemon
*/
async restart(options: { verbose?: boolean } = {}): Promise<void> {
await this.stop(options);
await new Promise(resolve => setTimeout(resolve, TIMING.ACTION_DELAY_NAVIGATION)); // Wait a bit
await this.start(options);
}
/**
* Get daemon status
*/
async getStatus(options: { verbose?: boolean } = {}): Promise<DaemonState | null> {
const { verbose = true } = options;
if (!(await this.isRunning())) {
if (verbose) {
console.log('❌ Daemon is not running');
}
return null;
}
try {
const client = new IPCClient();
const response = await client.sendRequest('status', {}, DAEMON.IPC_TIMEOUT);
client.close();
const state = response.data as DaemonState;
if (verbose) {
console.log('\n📊 Daemon Status:');
console.log(` Connected: ${state.connected ? '✓' : '✗'}`);
console.log(` Current URL: ${state.currentUrl || 'N/A'}`);
console.log(` Debug Port: ${state.debugPort || 'N/A'}`);
console.log(` Console Messages: ${state.consoleMessageCount}`);
console.log(` Network Errors: ${state.networkErrorCount}`);
console.log(` Uptime: ${Math.floor(state.uptime / TIMING.ACTION_DELAY_NAVIGATION)}s`);
console.log(` Last Activity: ${new Date(state.lastActivity).toLocaleTimeString()}`);
}
return state;
} catch (error) {
if (verbose) {
logger.error('Failed to get daemon status', error);
}
return null;
}
}
/**
* Check if daemon is running
*/
async isRunning(): Promise<boolean> {
const pid = await this.getPid();
if (!pid) {
return false;
}
try {
// Signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
} catch (_error) {
// Process doesn't exist, clean up stale PID file and invalidate cache
this.cachedPid = null;
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
}
return false;
}
}
/**
* Get daemon PID from PID file (with caching, async for non-blocking I/O)
*/
private async getPid(): Promise<number | null> {
// Use cached value if available and fresh
if (this.cachedPid && Date.now() - this.cachedPid.timestamp < this.PID_CACHE_TTL) {
return this.cachedPid.pid;
}
if (!existsSync(this.pidPath)) {
this.cachedPid = { pid: null, timestamp: Date.now() };
return null;
}
try {
const pidStr = await readFile(this.pidPath, 'utf-8');
const pid = parseInt(pidStr.trim(), 10);
const result = isNaN(pid) ? null : pid;
this.cachedPid = { pid: result, timestamp: Date.now() };
return result;
} catch (_error) {
this.cachedPid = { pid: null, timestamp: Date.now() };
return null;
}
}
/**
* Wait for daemon to start
*/
private async waitForDaemon(timeout: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await this.isRunning()) {
// Also check if socket is available
if (existsSync(this.socketPath) || process.platform === 'win32') {
return;
}
}
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
}
throw new Error('Daemon failed to start within timeout period');
}
/**
* Wait for daemon to stop
*/
private async waitForStop(timeout: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (!(await this.isRunning())) {
return;
}
await new Promise(resolve => setTimeout(resolve, TIMING.POLLING_INTERVAL_FAST));
}
throw new Error('Daemon failed to stop within timeout period');
}
/**
* Ensure daemon is running (auto-start if needed)
*/
async ensureRunning(options: { verbose?: boolean; initialUrl?: string } = {}): Promise<void> {
if (!(await this.isRunning())) {
await this.start(options);
}
}
/**
* Query interaction map for elements
*/
async queryMap(params: MapQueryParams, options: { verbose?: boolean } = {}): Promise<MapQueryResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('query-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE);
client.close();
const result = response.data as MapQueryResult;
if (verbose) {
console.log('\n🔍 Map Query Result:');
console.log(` Total matches: ${result.count}`);
if (result.count > 0) {
const firstResult = result.results[0];
console.log(` Best Selector: ${firstResult.selector}`);
console.log(` Element: ${firstResult.element.tag} - "${firstResult.element.text || '(no text)'}"`);
console.log(` Position: (${firstResult.element.position.x}, ${firstResult.element.position.y})`);
if (firstResult.alternatives.length > 0) {
console.log(` Alternatives: ${firstResult.alternatives.length} available`);
}
}
}
return result;
} catch (error) {
if (verbose) {
logger.error('Map query failed', error);
}
throw error;
}
}
/**
* Generate interaction map for current page
*/
async generateMap(params: MapGenerateParams, options: { verbose?: boolean } = {}): Promise<MapGenerateResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('generate-map', params as Record<string, unknown>, TIMING.WAIT_FOR_LOAD_STATE + DAEMON.IPC_TIMEOUT);
client.close();
const result = response.data as MapGenerateResult;
if (verbose) {
console.log('\n🗺 Interaction Map Generated:');
console.log(` URL: ${result.url}`);
console.log(` Elements: ${result.elementCount}`);
console.log(` Timestamp: ${result.timestamp}`);
console.log(` Cached: ${result.cached ? '✓' : '✗'}`);
}
return result;
} catch (error) {
if (verbose) {
logger.error('Map generation failed', error);
}
throw error;
}
}
/**
* Get interaction map status
*/
async getMapStatus(options: { verbose?: boolean } = {}): Promise<MapStatusResult> {
const { verbose = true } = options;
await this.ensureRunning({ verbose: false });
try {
const client = new IPCClient();
const response = await client.sendRequest('get-map-status', {}, DAEMON.IPC_TIMEOUT);
client.close();
const result = response.data as MapStatusResult;
if (verbose) {
console.log('\n📊 Interaction Map Status:');
console.log(` Exists: ${result.exists ? '✓' : '✗'}`);
if (result.exists) {
console.log(` URL: ${result.url || 'N/A'}`);
console.log(` Elements: ${result.elementCount}`);
console.log(` Timestamp: ${result.timestamp || 'N/A'}`);
console.log(` Cache Valid: ${result.cacheValid ? '✓' : '✗ (expired)'}`);
}
}
return result;
} catch (error) {
if (verbose) {
logger.error('Failed to get map status', error);
}
throw error;
}
}
}

View File

@@ -0,0 +1,527 @@
/**
* Interaction Map Manager for Browser Pilot Daemon
* Handles automatic map generation, caching, and lifecycle management
*/
import { EventEmitter } from 'events';
import { ChromeBrowser } from '../cdp/browser';
import { getInteractionMapScript, InteractionElement } from '../cdp/map/generate-interaction-map';
import { InteractionMap } from '../cdp/map/query-map';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { logger, getLocalTimestamp } from '../utils/logger';
import { FS, TIMING } from '../constants';
/**
* Map cache metadata for a single URL
*/
interface MapCacheEntry {
url: string;
timestamp: string;
elementCount: number;
mapFile: string;
}
/**
* Map cache file structure
*/
interface MapCacheFile {
version: string;
maps: MapCacheEntry[];
}
/**
* Map generation event data
*/
export interface MapGenerationEvent {
url: string;
timestamp: string;
elementCount: number;
}
/**
* Configuration constants for map management
*/
const MAP_CONFIG = {
CACHE_MAX_AGE_MS: TIMING.MAP_CACHE_TTL,
MAP_FILENAME: FS.INTERACTION_MAP_FILE,
CACHE_FILENAME: FS.MAP_CACHE_FILE,
MAP_FOLDER: FS.OUTPUT_DIR,
CACHE_VERSION: '1.0.0',
DEBOUNCE_MS: TIMING.NETWORK_IDLE_TIMEOUT,
MAP_GENERATION_DELAY_MS: TIMING.ACTION_DELAY_NAVIGATION
} as const;
export class MapManager extends EventEmitter {
private outputDir: string;
private mapPath: string;
private cachePath: string;
private currentCache: MapCacheFile | null = null;
private lastGenerationTime: number = 0;
private generationDebounceTimer: NodeJS.Timeout | null = null;
private isGenerating: boolean = false;
private currentGenerationPromise: Promise<InteractionMap> | null = null;
constructor(outputDir: string) {
super();
this.outputDir = outputDir;
this.mapPath = join(outputDir, MAP_CONFIG.MAP_FILENAME);
this.cachePath = join(outputDir, MAP_CONFIG.CACHE_FILENAME);
// Ensure output directory exists
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Load existing cache
this.currentCache = this.loadCache();
}
/**
* Generate interaction map for current page
*/
async generateMap(browser: ChromeBrowser, force: boolean = false): Promise<InteractionMap> {
this.isGenerating = true;
// Get current URL
const urlResult = await browser.sendCommand<{ result: { value: string } }>('Runtime.evaluate', {
expression: 'window.location.href',
returnByValue: true
});
const url = urlResult.result?.value || 'unknown';
// Emit generation start event
this.emit('generation-start', { url });
// Check if we should generate (unless forced)
if (!force && !this.shouldGenerateMapForUrl(url)) {
const cachedMap = this.loadMapFromFile();
if (cachedMap && cachedMap.url === url && cachedMap.ready === true) {
this.isGenerating = false;
this.emit('generation-complete', {
url: cachedMap.url,
timestamp: cachedMap.timestamp,
elementCount: cachedMap.statistics.total
});
return cachedMap;
}
}
// Write placeholder map with ready: false immediately
const placeholderMap: InteractionMap = {
url,
timestamp: getLocalTimestamp(),
ready: false,
viewport: { width: 0, height: 0 },
elements: {},
indexes: { byText: {}, byType: {}, inViewport: [] },
statistics: { total: 0, byType: {}, duplicates: 0 }
};
this.saveMapToFile(placeholderMap);
// Get viewport size
const viewportResult = await browser.sendCommand<{
layoutViewport: { clientWidth: number; clientHeight: number };
}>('Page.getLayoutMetrics', {});
const viewport = {
width: viewportResult.layoutViewport.clientWidth,
height: viewportResult.layoutViewport.clientHeight
};
// Execute script to find all interactive elements
const script = getInteractionMapScript();
interface RuntimeEvaluateResult {
result?: {
type?: string;
value?: unknown;
description?: string;
};
exceptionDetails?: {
exception?: {
description?: string;
};
text?: string;
};
}
const result = await browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: script,
returnByValue: true
});
// Check for script execution errors
if (result.exceptionDetails) {
const errorMsg = result.exceptionDetails.exception?.description ||
result.exceptionDetails.text ||
'Unknown script error';
logger.error('Map generation script error:', errorMsg);
throw new Error(`Failed to extract interactive elements: ${errorMsg}`);
}
if (!result.result || !result.result.value) {
logger.error('Unexpected result structure:', JSON.stringify(result, null, 2));
throw new Error('Failed to extract interactive elements: No value returned');
}
const elementsArray = result.result.value as InteractionElement[];
// Generate statistics
const byType: Record<string, number> = {};
const textCounts: Record<string, number> = {};
elementsArray.forEach(el => {
byType[el.type] = (byType[el.type] || 0) + 1;
if (el.text) {
textCounts[el.text] = (textCounts[el.text] || 0) + 1;
}
});
const duplicates = Object.values(textCounts).filter(count => count > 1).length;
// Add indexed selectors for duplicates
elementsArray.forEach(el => {
if (el.text && textCounts[el.text] > 1 && el.selectors.byText) {
const sameTextElements = elementsArray.filter(e => e.text === el.text);
const index = sameTextElements.indexOf(el) + 1;
el.selectors.byText = `(${el.selectors.byText})[${index}]`;
}
});
// Convert array to key-value structure
const elements: Record<string, InteractionElement> = {};
const textIndex: Record<string, string[]> = {};
const typeIndex: Record<string, string[]> = {};
const inViewportIds: string[] = [];
elementsArray.forEach(el => {
// Add to elements map
elements[el.id] = el;
// Build text index
if (el.text) {
if (!textIndex[el.text]) {
textIndex[el.text] = [];
}
textIndex[el.text].push(el.id);
}
// Build type index
if (!typeIndex[el.type]) {
typeIndex[el.type] = [];
}
typeIndex[el.type].push(el.id);
// Build viewport index
if (el.visibility.inViewport) {
inViewportIds.push(el.id);
}
});
const timestamp = getLocalTimestamp();
// Build map object with all data collected
const map: InteractionMap = {
url,
timestamp,
ready: true, // All data collected, ready to write
viewport,
elements,
indexes: {
byText: textIndex,
byType: typeIndex,
inViewport: inViewportIds
},
statistics: {
total: elementsArray.length,
byType,
duplicates
}
};
try {
// Save complete map to file in one write
this.saveMapToFile(map);
// Update cache metadata
this.updateCacheEntry(url, timestamp, map.statistics.total);
// Update last generation time
this.lastGenerationTime = Date.now();
// Emit generation complete event
this.emit('generation-complete', {
url: map.url,
timestamp: map.timestamp,
elementCount: map.statistics.total
});
return map;
} catch (error) {
this.emit('generation-error', error);
throw error;
} finally {
this.isGenerating = false;
}
}
/**
* Generate map with lock to prevent concurrent executions
* Returns a promise that resolves when map generation is complete
*/
async generateMapSerially(browser: ChromeBrowser, force: boolean = false): Promise<void> {
// If already generating and not forced, return existing promise
if (this.currentGenerationPromise && !force) {
logger.debug('Map generation already in progress, waiting for completion...');
await this.currentGenerationPromise;
return;
}
// Clear existing debounce timer (legacy support)
if (this.generationDebounceTimer) {
logger.debug(`Canceling previous map generation timer`);
clearTimeout(this.generationDebounceTimer);
}
// Generate with lock to prevent concurrent execution
try {
logger.debug('Generating map with lock...');
this.currentGenerationPromise = this.generateMap(browser, force);
await this.currentGenerationPromise;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Map generation failed: ${errorMessage}`);
this.emit('generation-error', error);
throw error;
} finally {
this.currentGenerationPromise = null;
}
}
/**
* Check if map should be generated for a URL
*/
private shouldGenerateMapForUrl(url: string): boolean {
if (!this.currentCache) {
return true;
}
// Find cache entry for this URL
const entry = this.currentCache.maps.find(m => m.url === url);
if (!entry) {
return true;
}
// Check if cache is still valid
const cacheAge = Date.now() - new Date(entry.timestamp).getTime();
return cacheAge > MAP_CONFIG.CACHE_MAX_AGE_MS;
}
/**
* Check if cached map is valid for a URL
*/
isCacheValid(url: string): boolean {
return !this.shouldGenerateMapForUrl(url);
}
/**
* Get map status for a URL
*/
getMapStatus(url: string): {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
} {
const mapExists = existsSync(this.mapPath);
if (!mapExists || !this.currentCache) {
return {
exists: false,
url: null,
timestamp: null,
elementCount: 0,
cacheValid: false
};
}
const entry = this.currentCache.maps.find(m => m.url === url);
if (!entry) {
return {
exists: mapExists,
url: null,
timestamp: null,
elementCount: 0,
cacheValid: false
};
}
return {
exists: true,
url: entry.url,
timestamp: entry.timestamp,
elementCount: entry.elementCount,
cacheValid: this.isCacheValid(url)
};
}
/**
* Load map cache from file
*/
private loadCache(): MapCacheFile {
if (!existsSync(this.cachePath)) {
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
}
try {
const data = readFileSync(this.cachePath, 'utf-8');
const parsed = JSON.parse(data) as unknown;
// Type guard to validate cache structure
if (
typeof parsed === 'object' &&
parsed !== null &&
'version' in parsed &&
'maps' in parsed &&
Array.isArray((parsed as { maps: unknown }).maps)
) {
return parsed as MapCacheFile;
}
// Invalid structure, return empty cache
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
} catch (_error: unknown) {
logger.warn('Failed to load map cache, starting fresh');
return {
version: MAP_CONFIG.CACHE_VERSION,
maps: []
};
}
}
/**
* Save map cache to file
*/
private saveCache(cache: MapCacheFile): void {
try {
writeFileSync(this.cachePath, JSON.stringify(cache, null, 2), 'utf-8');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to save map cache: ${errorMessage}`);
}
}
/**
* Update cache entry for a URL
*/
private updateCacheEntry(url: string, timestamp: string, elementCount: number): void {
if (!this.currentCache) {
this.currentCache = this.loadCache();
}
// Remove old entry for this URL
this.currentCache.maps = this.currentCache.maps.filter(m => m.url !== url);
// Add new entry
this.currentCache.maps.push({
url,
timestamp,
elementCount,
mapFile: MAP_CONFIG.MAP_FILENAME
});
// Save updated cache
this.saveCache(this.currentCache);
}
/**
* Load map from file
*/
private loadMapFromFile(): InteractionMap | null {
if (!existsSync(this.mapPath)) {
return null;
}
try {
const data = readFileSync(this.mapPath, 'utf-8');
return JSON.parse(data) as InteractionMap;
} catch (_error: unknown) {
return null;
}
}
/**
* Save map to file
*/
private saveMapToFile(map: InteractionMap): void {
try {
writeFileSync(this.mapPath, JSON.stringify(map, null, 2), 'utf-8');
} catch (error: unknown) {
throw new Error(`Failed to save map file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Wait for ongoing map generation to complete
* @param timeout Maximum wait time in milliseconds (default: 10000)
* @returns true if generation completed successfully, false if timeout
*/
async waitForGeneration(timeout: number = TIMING.WAIT_FOR_LOAD_STATE): Promise<boolean> {
if (!this.isGenerating && !this.currentGenerationPromise) {
return true; // Already ready
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
logger.warn('Map generation timeout');
this.removeListener('generation-complete', onComplete);
this.removeListener('generation-error', onError);
resolve(false);
}, timeout);
const onComplete = () => {
clearTimeout(timer);
this.removeListener('generation-error', onError);
resolve(true);
};
const onError = () => {
clearTimeout(timer);
this.removeListener('generation-complete', onComplete);
resolve(false);
};
this.once('generation-complete', onComplete);
this.once('generation-error', onError);
});
}
/**
* Set ready flag in existing map file
* Called by action handlers to invalidate map before action execution
* @param ready Ready state to set (typically false to invalidate)
*/
setMapReady(ready: boolean): void {
try {
const map = this.loadMapFromFile();
if (!map) {
return; // No map to update
}
map.ready = ready;
this.saveMapToFile(map);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to update map ready flag: ${errorMessage}`);
}
}
}

View File

@@ -0,0 +1,265 @@
/**
* IPC Protocol definitions for Browser Pilot Daemon
*/
// Type imports for protocol definitions (used by type definitions below)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { ConsoleMessage, NetworkError, FormattedConsoleMessage } from '../cdp/browser';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { QueryOptions, QueryResult, InteractionMap, InteractionElement } from '../cdp/map/query-map';
/**
* IPC Request from CLI to Daemon
*/
export interface IPCRequest {
id: string;
command: string;
params: Record<string, unknown>;
timeout?: number;
}
/**
* IPC Response from Daemon to CLI
*/
export interface IPCResponse {
id: string;
success: boolean;
data?: unknown;
error?: string;
}
/**
* Daemon state information
*/
export interface DaemonState {
connected: boolean;
currentUrl: string | null;
targetId: string | null;
debugPort: number | null;
consoleMessageCount: number;
networkErrorCount: number;
uptime: number;
lastActivity: number;
}
/**
* Command-specific parameter interfaces
*/
export interface NavigateParams {
url: string;
waitForLoad?: boolean;
timeout?: number;
}
export interface ClickParams {
selector: string;
waitForSelector?: boolean;
timeout?: number;
}
export interface FillParams {
selector: string;
value: string;
clear?: boolean;
}
export interface ScrollParams {
x: number;
y: number;
}
export interface EvaluateParams {
expression: string;
returnByValue?: boolean;
}
export interface ScreenshotParams {
filename?: string;
fullPage?: boolean;
quality?: number;
}
export interface ConsoleParams {
errorsOnly?: boolean;
clear?: boolean;
}
export interface WaitParams {
selector?: string;
timeout?: number;
duration?: number;
}
/**
* Map-related parameter interfaces
*/
export interface MapQueryParams extends QueryOptions {
// Extends QueryOptions which already has: text, type, index, viewportOnly, id
}
export interface MapGenerateParams {
force?: boolean;
useCache?: boolean;
}
/**
* Command result interfaces
*/
export interface ConsoleResult {
messages: FormattedConsoleMessage[];
count: number;
errorCount: number;
warningCount: number;
logCount: number;
}
export interface NavigateResult {
url: string;
title?: string;
}
export interface ScreenshotResult {
path: string;
size: number;
}
/**
* Map-related result interfaces
*/
export interface MapQueryResultItem {
selector: string;
alternatives: string[];
element: {
tag: string;
text: string | undefined;
position: { x: number; y: number };
};
}
export interface MapQueryResult {
count: number;
results: MapQueryResultItem[];
// Optional fields for list operations
types?: Record<string, number>; // For listTypes
texts?: Array<{ text: string; type: string; count: number }>; // For listTexts
total?: number; // Total count before pagination
}
export interface MapStatusResult {
exists: boolean;
url: string | null;
timestamp: string | null;
elementCount: number;
cacheValid: boolean;
}
export interface MapGenerateResult {
success: boolean;
url: string;
elementCount: number;
timestamp: string;
cached: boolean;
}
/**
* Protocol constants
*/
export const SOCKET_PATH_PREFIX = 'daemon';
export const PID_FILENAME = 'daemon.pid';
export const STATE_FILENAME = 'daemon-state.json';
export const DEFAULT_TIMEOUT = 30000; // 30 seconds
export const IDLE_SHUTDOWN_TIMEOUT = 1800000; // 30 minutes
/**
* Get project-specific socket name
* Uses project folder name + path hash to create unique socket for each project
*/
export function getProjectSocketName(): string {
const { basename } = require('path');
const { findProjectRoot } = require('../cdp/utils');
const { createHash } = require('crypto');
const projectRoot = findProjectRoot();
const projectName = basename(projectRoot)
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace special chars with hyphen
.toLowerCase();
// Add hash of full path to prevent collision
const hash = createHash('sha256')
.update(projectRoot)
.digest('hex')
.substring(0, 8); // Use first 8 chars for brevity
return `${SOCKET_PATH_PREFIX}-${projectName}-${hash}`;
}
/**
* Protocol errors
*/
export class IPCError extends Error {
constructor(
message: string,
public code: string
) {
super(message);
this.name = 'IPCError';
}
}
export const IPCErrorCodes = {
TIMEOUT: 'TIMEOUT',
DAEMON_NOT_RUNNING: 'DAEMON_NOT_RUNNING',
DAEMON_ALREADY_RUNNING: 'DAEMON_ALREADY_RUNNING',
BROWSER_NOT_CONNECTED: 'BROWSER_NOT_CONNECTED',
COMMAND_FAILED: 'COMMAND_FAILED',
INVALID_REQUEST: 'INVALID_REQUEST',
CONNECTION_ERROR: 'CONNECTION_ERROR'
} as const;
/**
* Daemon command constants
*/
export const DAEMON_COMMANDS = {
// Navigation
NAVIGATE: 'navigate',
BACK: 'back',
FORWARD: 'forward',
RELOAD: 'reload',
// Interaction
CLICK: 'click',
FILL: 'fill',
HOVER: 'hover',
PRESS: 'press',
TYPE: 'type',
// Capture
SCREENSHOT: 'screenshot',
PDF: 'pdf',
// Data
EXTRACT: 'extract',
CONTENT: 'content',
FIND: 'find',
EVAL: 'eval',
// Console
CONSOLE: 'console',
// Wait
WAIT: 'wait',
WAIT_IDLE: 'wait-idle',
SLEEP: 'sleep',
// Scroll
SCROLL: 'scroll',
// Daemon management
DAEMON_STATUS: 'daemon-status',
DAEMON_STOP: 'daemon-stop',
// Map operations
QUERY_MAP: 'query-map',
GENERATE_MAP: 'generate-map',
GET_MAP_STATUS: 'get-map-status'
} as const;
export type DaemonCommand = typeof DAEMON_COMMANDS[keyof typeof DAEMON_COMMANDS];

View File

@@ -0,0 +1,880 @@
/**
* Browser Pilot Daemon Server
* Maintains persistent CDP connection and handles IPC requests from CLI
*/
import { createServer, Server, Socket } from 'net';
import { join, basename } from 'path';
import { existsSync, unlinkSync, writeFileSync, readFileSync } from 'fs';
import { ChromeBrowser } from '../cdp/browser';
import { getOutputDir, loadSharedConfig } from '../cdp/config';
import { RuntimeEvaluateResult } from '../cdp/actions/helpers';
import { waitForDomStable } from '../cdp/actions/wait';
import {
IPCRequest,
IPCResponse,
IPCError,
IPCErrorCodes,
SOCKET_PATH_PREFIX,
PID_FILENAME,
IDLE_SHUTDOWN_TIMEOUT,
getProjectSocketName
} from './protocol';
import { MapManager } from './map-manager';
import { logger } from '../utils/logger';
import { TIME_CONVERSION } from '../constants';
import * as handlers from './handlers';
import { loadLastUrl } from './handlers/navigation-handlers';
export class DaemonServer {
private server: Server | null = null;
private browser: ChromeBrowser | null = null;
private socketPath: string;
private pidPath: string;
private outputDir: string;
private idleTimeout: NodeJS.Timeout | null = null;
private lastActivity: number = Date.now();
private startTime: number = Date.now();
private isShuttingDown: boolean = false;
private shutdownPromise: Promise<void> | null = null;
private mapManager: MapManager | null = null;
private pendingNetworkRequests: Set<string> = new Set();
private mapGenerationInProgress: boolean = false;
private activeSockets: Set<Socket> = new Set();
private initialUrl: string | undefined;
private readonly MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
constructor() {
this.outputDir = getOutputDir();
this.socketPath = this.getSocketPath();
this.pidPath = join(this.outputDir, PID_FILENAME);
this.mapManager = new MapManager(this.outputDir);
}
/**
* Get socket path (platform-specific, project-unique)
*/
private getSocketPath(): string {
if (process.platform === 'win32') {
// Windows: project-specific named pipe
const socketName = getProjectSocketName();
return `\\\\.\\pipe\\${socketName}`;
} else {
// Unix domain socket (already project-specific via outputDir)
return join(this.outputDir, `${SOCKET_PATH_PREFIX}.sock`);
}
}
/**
* Start daemon server
*/
async start(): Promise<void> {
// Enable file logging for daemon
const logFile = join(this.outputDir, 'daemon.log');
logger.enableFileLogging(logFile);
logger.info('🚀 Browser Pilot Daemon starting...');
logger.info(`Log file: ${logFile}`);
// Store initial URL from environment
this.initialUrl = process.env.BP_INITIAL_URL;
// Check if already running
if (this.isAlreadyRunning()) {
throw new IPCError('Daemon already running', IPCErrorCodes.DAEMON_ALREADY_RUNNING);
}
// Clean up stale socket file (Unix only)
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
unlinkSync(this.socketPath);
}
// Initialize browser connection
logger.info('Starting Browser Pilot Daemon...');
this.browser = new ChromeBrowser(false);
try {
// Try to connect to existing browser first
await this.browser.connect();
logger.info('Connected to existing Chrome instance');
} catch (_error) {
// If no browser running, launch new one
if (this.initialUrl) {
logger.info(`Launching new Chrome instance with initial URL: ${this.initialUrl}`);
await this.browser.launch(this.initialUrl);
this.initialUrl = undefined; // Clear after use
} else {
logger.info('Launching new Chrome instance...');
await this.browser.launch();
}
logger.info('Chrome launched successfully');
}
// Set up Page domain for navigation events
await this.setupPageDomain();
// Set up Network tracking for auto-wait
await this.setupNetworkTracking();
// Auto-restore last visited URL if enabled
await this.autoRestoreUrl();
// Create IPC server
this.server = createServer((socket) => this.handleConnection(socket));
// Start listening
this.server.listen(this.socketPath, () => {
logger.info(`IPC server listening on ${this.socketPath}`);
this.writePidFile();
this.startIdleTimer();
logger.info('Browser Pilot Daemon is ready');
});
// Handle server errors
this.server.on('error', (error) => {
logger.error('Server error', error);
// For EADDRINUSE, exit immediately to allow DaemonManager retry logic
if ('code' in error && error.code === 'EADDRINUSE') {
logger.error('Address already in use. Exiting for retry...');
process.exit(1);
}
this.shutdown();
});
// Setup graceful shutdown
// Use async wrapper to properly await shutdown completion
process.on('SIGINT', () => {
this.shutdown().catch((error) => {
logger.error('Error during SIGINT shutdown', error);
process.exit(1);
});
});
process.on('SIGTERM', () => {
this.shutdown().catch((error) => {
logger.error('Error during SIGTERM shutdown', error);
process.exit(1);
});
});
}
/**
* Auto-restore last visited URL if enabled
*/
private async autoRestoreUrl(): Promise<void> {
if (!this.browser) return;
try {
// Load shared config
const config = loadSharedConfig();
const projectRoot = process.cwd();
const projectName = basename(projectRoot);
const projectConfig = config.projects[projectName];
// Check if autoRestore is enabled (default: true)
const autoRestore = projectConfig?.autoRestore !== false;
if (!autoRestore) {
logger.debug('Auto-restore disabled, skipping URL restoration');
return;
}
// Load last visited URL
const lastUrl = await loadLastUrl(this.outputDir);
if (!lastUrl) {
logger.debug('No last URL found, skipping restoration');
return;
}
logger.info(`🔄 Auto-restoring last visited URL: ${lastUrl}`);
// Navigate to last URL
await this.browser.sendCommand('Page.navigate', { url: lastUrl });
logger.info('✅ URL restored successfully');
} catch (error) {
logger.warn(`Failed to auto-restore URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Setup Page domain for navigation events
*/
private async setupPageDomain(): Promise<void> {
if (!this.browser) return;
try {
await this.browser.sendCommand('Page.enable');
// Listen for frame navigation to auto-clear console
this.browser.client?.on('Page.frameNavigated', (params: { frame: { id: string; parentId?: string; url: string } }) => {
// Only process main frame navigation (no parent)
if (!params.frame.parentId) {
logger.info(`🔄 Main frame navigated to: ${params.frame.url}`);
if (this.browser) {
this.browser.clearConsoleMessages();
this.browser.clearNetworkErrors();
}
}
});
// Listen for page load complete to ensure stable DOM
this.browser.client?.on('Page.loadEventFired', async () => {
logger.info('📄 Page load complete');
await this.generateMapAfterStabilization();
});
// Listen for SPA navigation (History API usage)
this.browser.client?.on('Page.navigatedWithinDocument', async (params: {
frameId: string;
url: string;
navigationType: 'fragment' | 'historyApi' | 'other';
}) => {
// Ignore fragment navigation (same page anchor links)
if (params.navigationType === 'fragment') {
logger.debug(`🔗 Fragment navigation ignored: ${params.url}`);
return;
}
// SPA routing detected (History API: pushState/replaceState)
logger.info(`🔄 SPA navigation detected (${params.navigationType}): ${params.url}`);
// Clear console/network errors for new route
if (this.browser) {
this.browser.clearConsoleMessages();
this.browser.clearNetworkErrors();
}
// Generate map after DOM stabilization (skip loadEventFired for SPA)
await this.generateMapAfterStabilization(true);
});
logger.info('Page navigation listeners enabled (full page + SPA)');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Could not enable Page domain: ${errorMessage}`);
}
}
/**
* Setup network request tracking
*/
private async setupNetworkTracking(): Promise<void> {
if (!this.browser) return;
try {
await this.browser.sendCommand('Network.enable');
this.browser.client?.on('Network.requestWillBeSent', (params: {
requestId: string;
type: string;
request: { url: string };
}) => {
logger.debug(`📡 Network request: ${params.type}${params.request?.url || 'unknown'}`);
if (params.type === 'XHR' || params.type === 'Fetch') {
this.pendingNetworkRequests.add(params.requestId);
logger.info(`📤 XHR/Fetch started: ${params.request?.url || 'unknown'} (${this.pendingNetworkRequests.size} pending)`);
}
});
this.browser.client?.on('Network.responseReceived', (params: {
requestId: string;
}) => {
if (this.pendingNetworkRequests.has(params.requestId)) {
this.pendingNetworkRequests.delete(params.requestId);
logger.info(`📥 XHR/Fetch completed (${this.pendingNetworkRequests.size} pending)`);
}
});
this.browser.client?.on('Network.loadingFailed', (params: {
requestId: string;
}) => {
if (this.pendingNetworkRequests.has(params.requestId)) {
this.pendingNetworkRequests.delete(params.requestId);
logger.info(`❌ XHR/Fetch failed (${this.pendingNetworkRequests.size} pending)`);
}
});
logger.info('Network tracking enabled');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Could not enable Network tracking: ${errorMessage}`);
}
}
/**
* Generate map after DOM stabilization
* @param skipLoadEvent Skip waiting for Page.loadEventFired (for SPA navigation)
*/
private async generateMapAfterStabilization(skipLoadEvent: boolean = false): Promise<void> {
if (!this.mapManager || !this.browser) return;
// Prevent concurrent map generation
if (this.mapGenerationInProgress) {
logger.debug(`⏭️ Skipping map generation (already in progress)`);
return;
}
this.mapGenerationInProgress = true;
try {
logger.debug(`🔨 Map generation requested (skipLoadEvent: ${skipLoadEvent})`);
// Mark map as not ready while generating (for chain commands)
if (this.mapManager) {
this.mapManager.setMapReady(false);
logger.debug('📝 Map marked as not ready (generating...)');
}
// Wait for Page.loadEventFired only for full page loads
if (!skipLoadEvent) {
await new Promise<void>((resolve) => {
const onLoad = () => {
this.browser?.client?.off('Page.loadEventFired', onLoad);
logger.debug('✓ Page load event fired');
resolve();
};
// Add listener
this.browser?.client?.once('Page.loadEventFired', onLoad);
// Timeout fallback
setTimeout(() => {
this.browser?.client?.off('Page.loadEventFired', onLoad);
logger.warn('⚠️ Page load event timeout, continuing anyway');
resolve();
}, 5000);
});
} else {
logger.info('⏭️ Skipping Page.loadEventFired (SPA navigation)');
// Wait for React/Vue to start making network requests after SPA navigation
logger.info('⏳ Waiting for SPA to start network requests (100ms)...');
await new Promise(resolve => setTimeout(resolve, 100));
}
// Wait for network idle (all XHR/Fetch requests complete)
logger.info('⏳ Waiting for network idle...');
const networkIdleStart = Date.now();
const networkIdleTimeout = 10000; // 10s max wait
while (this.pendingNetworkRequests.size > 0) {
if (Date.now() - networkIdleStart > networkIdleTimeout) {
logger.warn(`⚠️ Network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
if (this.pendingNetworkRequests.size === 0) {
logger.info(`✓ Network idle (waited ${Date.now() - networkIdleStart}ms)`);
}
// Wait for browser to be idle (React/Vue rendering complete)
logger.info('⏳ Waiting for browser idle (rendering complete)...');
const idleScript = `
new Promise((resolve) => {
const startTime = Date.now();
if (typeof requestIdleCallback !== 'undefined') {
// Browser supports requestIdleCallback
const idleId = requestIdleCallback(() => {
resolve({ waited: Date.now() - startTime });
}, { timeout: 2000 });
// Safety timeout
setTimeout(() => {
cancelIdleCallback(idleId);
resolve({ waited: Date.now() - startTime, timeout: true });
}, 3000);
} else {
// Fallback for browsers without requestIdleCallback (Safari)
setTimeout(() => {
resolve({ waited: Date.now() - startTime, fallback: true });
}, 0);
}
})
`;
try {
const result = await this.browser.sendCommand<RuntimeEvaluateResult>('Runtime.evaluate', {
expression: idleScript,
awaitPromise: true,
returnByValue: true
});
const data = result.result?.value as { waited: number; timeout?: boolean; fallback?: boolean };
if (data.timeout) {
logger.info(`✓ Browser idle timeout (waited ${data.waited}ms)`);
} else if (data.fallback) {
logger.info(`✓ Browser idle fallback (waited ${data.waited}ms)`);
} else {
logger.info(`✓ Browser idle (waited ${data.waited}ms)`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ Browser idle check failed: ${errorMessage}`);
}
// Wait for DOM to stabilize (100ms of no mutations)
await waitForDomStable(this.browser, 100, 10000, { verbose: false });
logger.info('✓ DOM stabilized');
// Check again for pending network requests (may have started during DOM stabilization)
if (this.pendingNetworkRequests.size > 0) {
logger.info(`⏳ Waiting for network requests triggered during DOM stabilization (${this.pendingNetworkRequests.size} pending)...`);
const postDomNetworkStart = Date.now();
const postDomNetworkTimeout = 10000;
while (this.pendingNetworkRequests.size > 0) {
if (Date.now() - postDomNetworkStart > postDomNetworkTimeout) {
logger.warn(`⚠️ Post-DOM network idle timeout (${this.pendingNetworkRequests.size} requests still pending)`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
if (this.pendingNetworkRequests.size === 0) {
logger.info(`✓ Post-DOM network idle (waited ${Date.now() - postDomNetworkStart}ms)`);
}
}
logger.info('✓ Generating interaction map...');
// Generate map with debounce
await this.mapManager.generateMapSerially(this.browser, false).catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ Auto map generation failed: ${errorMessage}`);
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`⚠️ DOM stabilization failed: ${errorMessage}`);
} finally {
// Release lock
this.mapGenerationInProgress = false;
}
}
/**
* Check if daemon is already running
*/
private isAlreadyRunning(): boolean {
if (!existsSync(this.pidPath)) {
return false;
}
try {
const pidStr = readFileSync(this.pidPath, 'utf-8');
const pid = parseInt(pidStr, 10);
// Check if process with this PID exists
process.kill(pid, 0); // Signal 0 checks existence without killing
return true;
} catch (_error) {
// Process doesn't exist, clean up stale PID file
unlinkSync(this.pidPath);
return false;
}
}
/**
* Write PID file
*/
private writePidFile(): void {
writeFileSync(this.pidPath, String(process.pid), 'utf-8');
}
/**
* Start idle timer for auto-shutdown
*/
private startIdleTimer(): void {
this.resetIdleTimer();
}
/**
* Reset idle timer
*/
private resetIdleTimer(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
this.idleTimeout = setTimeout(() => {
const idleTime = Date.now() - this.lastActivity;
const idleSeconds = Math.floor(idleTime / TIME_CONVERSION.MS_PER_SECOND);
logger.info(`⏱️ Idle for ${idleSeconds}s, shutting down...`);
this.shutdown();
}, IDLE_SHUTDOWN_TIMEOUT);
}
/**
* Handle client connection
*/
private handleConnection(socket: Socket): void {
logger.debug('🔗 Client connected');
// Track active socket
this.activeSockets.add(socket);
let buffer = '';
socket.on('data', async (data) => {
buffer += data.toString();
// Check buffer size to prevent memory exhaustion
if (buffer.length > this.MAX_MESSAGE_SIZE) {
logger.error('Message size exceeds limit, closing connection');
socket.destroy();
return;
}
// Process complete JSON messages (delimited by newline)
const messages = buffer.split('\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim()) continue;
try {
const request: IPCRequest = JSON.parse(message);
// Validate request structure
if (!request.id || !request.command) {
throw new Error('Invalid request structure: missing id or command');
}
const response = await this.handleRequest(request);
socket.write(JSON.stringify(response) + '\n');
} catch (error) {
const errorResponse: IPCResponse = {
id: 'unknown',
success: false,
error: error instanceof Error ? error.message : String(error)
};
socket.write(JSON.stringify(errorResponse) + '\n');
}
}
});
socket.on('end', () => {
logger.info('Client disconnected');
this.activeSockets.delete(socket);
});
socket.on('error', (error) => {
logger.error('Socket error', error);
this.activeSockets.delete(socket);
});
}
/**
* Handle IPC request
*/
private async handleRequest(request: IPCRequest): Promise<IPCResponse> {
this.lastActivity = Date.now();
this.resetIdleTimer();
logger.debug(`📨 Received command: ${request.command}`);
if (!this.browser) {
return {
id: request.id,
success: false,
error: 'Browser not connected'
};
}
try {
let result: unknown;
// Create handler context
const context: handlers.HandlerContext = {
browser: this.browser,
mapManager: this.mapManager || undefined,
outputDir: this.outputDir
};
switch (request.command) {
// Navigation commands
case 'navigate':
result = await handlers.handleNavigate(context, request.params);
break;
case 'back':
result = await handlers.handleBack(context, request.params);
break;
case 'forward':
result = await handlers.handleForward(context, request.params);
break;
case 'reload':
result = await handlers.handleReload(context, request.params);
break;
// Interaction commands
case 'click':
result = await handlers.handleClick(context, request.params);
break;
case 'fill':
result = await handlers.handleFill(context, request.params);
break;
case 'hover':
result = await handlers.handleHover(context, request.params);
break;
case 'press':
result = await handlers.handlePress(context, request.params);
break;
case 'type':
result = await handlers.handleType(context, request.params);
break;
// Capture commands
case 'screenshot':
result = await handlers.handleScreenshot(context, request.params);
break;
case 'pdf':
result = await handlers.handlePdf(context, request.params);
break;
case 'set-viewport':
result = await handlers.handleSetViewport(context, request.params);
break;
case 'get-viewport':
result = await handlers.handleGetViewport(context, request.params);
break;
case 'get-screen-info':
result = await handlers.handleGetScreenInfo(context, request.params);
break;
// Data commands
case 'extract':
result = await handlers.handleExtract(context, request.params);
break;
case 'content':
result = await handlers.handleContent(context, request.params);
break;
case 'find':
result = await handlers.handleFind(context, request.params);
break;
case 'eval':
result = await handlers.handleEval(context, request.params);
break;
// Map commands
case 'query-map':
result = await handlers.handleQueryMap(context, request.params);
break;
case 'generate-map':
result = await handlers.handleGenerateMap(context, request.params);
break;
case 'get-map-status':
result = await handlers.handleGetMapStatus(context, request.params);
break;
// Utility commands
case 'scroll':
result = await handlers.handleScroll(context, request.params);
break;
case 'wait':
result = await handlers.handleWait(context, request.params);
break;
case 'console':
result = await handlers.handleConsole(context, request.params);
break;
case 'status':
result = await handlers.handleStatus(context, request.params, this.startTime, this.lastActivity);
break;
// Daemon management
case 'shutdown':
setImmediate(() => this.shutdown());
result = { message: 'Daemon shutting down...' };
break;
default:
throw new IPCError(`Unknown command: ${request.command}`, IPCErrorCodes.INVALID_REQUEST);
}
return {
id: request.id,
success: true,
data: result
};
} catch (error) {
logger.error(`Command failed: ${request.command}`, error);
return {
id: request.id,
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Graceful shutdown
*/
async shutdown(): Promise<void> {
// Return existing shutdown promise if already shutting down
if (this.shutdownPromise) {
return this.shutdownPromise;
}
this.shutdownPromise = this._doShutdown();
return this.shutdownPromise;
}
/**
* Internal shutdown implementation
*/
private async _doShutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
logger.info('Shutting down Browser Pilot Daemon...');
// Stop idle timer
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
// Remove process signal listeners
process.removeAllListeners('SIGINT');
process.removeAllListeners('SIGTERM');
// Close browser first
if (this.browser) {
try {
await this.browser.close();
logger.info('Browser closed');
} catch (error) {
logger.error('Error closing browser', error);
}
}
// Force close all active socket connections
if (this.activeSockets.size > 0) {
logger.info(`Closing ${this.activeSockets.size} active socket connection(s)...`);
for (const socket of this.activeSockets) {
try {
socket.destroy();
} catch (error) {
logger.error('Error destroying socket', error);
}
}
this.activeSockets.clear();
logger.info('All socket connections closed');
}
// Close IPC server (wait for all connections to close with timeout)
if (this.server) {
const server = this.server;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
logger.warn('IPC server close timed out after 2 seconds. Continuing shutdown.');
resolve();
}, 2000);
server.close((err?: Error) => {
clearTimeout(timeout);
if (err) {
logger.error('Error closing IPC server', err);
} else {
logger.info('IPC server closed');
}
resolve();
});
});
}
// Clean up socket file (Unix only) - safe after server.close() completes
if (process.platform !== 'win32' && existsSync(this.socketPath)) {
try {
unlinkSync(this.socketPath);
logger.info('Socket file removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove socket file: ${errorMsg}`);
}
}
// Remove PID file
if (existsSync(this.pidPath)) {
unlinkSync(this.pidPath);
logger.info('PID file removed');
}
// Remove interaction map cache files
const mapPath = join(this.outputDir, 'interaction-map.json');
const mapCachePath = join(this.outputDir, 'map-cache.json');
if (existsSync(mapPath)) {
try {
unlinkSync(mapPath);
logger.info('Interaction map cache removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove interaction map: ${errorMsg}`);
}
}
if (existsSync(mapCachePath)) {
try {
unlinkSync(mapCachePath);
logger.info('Map cache metadata removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove map cache metadata: ${errorMsg}`);
}
}
// Remove shutdown request flag (if exists from SessionEnd)
// This flag is created by SessionEnd (cleanup-config.js) to track daemon shutdown
const shutdownFlagPath = join(this.outputDir, 'daemon-to-stop.pid');
if (existsSync(shutdownFlagPath)) {
try {
unlinkSync(shutdownFlagPath);
logger.info('Shutdown request flag removed');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to remove shutdown flag: ${errorMsg}`);
// Fallback: Mark as COMPLETED so next SessionStart knows shutdown succeeded
// Even if file can't be deleted (Windows file lock), marking it prevents force-kill attempt
try {
writeFileSync(shutdownFlagPath, `COMPLETED:${process.pid}`, 'utf-8');
logger.info('Marked shutdown flag as COMPLETED (deletion failed due to file lock)');
} catch (_writeError) {
logger.error('Failed to mark shutdown flag as COMPLETED');
}
}
}
logger.info('Daemon shutdown complete');
process.exit(0);
}
/**
* Get current browser instance (for testing)
*/
get currentBrowser(): ChromeBrowser | null {
return this.browser;
}
/**
* Expose client property for Page event listener
*/
get client() {
return this.browser?.client;
}
}
// Start daemon if run directly
if (require.main === module) {
const daemon = new DaemonServer();
daemon.start().catch((error) => {
logger.error('Failed to start daemon', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,242 @@
/**
* Logger utility for CLI commands
* Provides consistent logging with verbosity control
*/
import { writeFileSync, appendFileSync } from 'fs';
import { dirname } from 'path';
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3,
VERBOSE = 4
}
export interface LoggerOptions {
level?: LogLevel;
prefix?: string;
timestamp?: boolean;
logFile?: string;
}
/**
* Get default log level from environment variable
* Set BP_LOG_LEVEL=DEBUG to change log level
*/
function getDefaultLogLevel(): LogLevel {
const envLevel = process.env.BP_LOG_LEVEL?.toUpperCase();
switch (envLevel) {
case 'ERROR': return LogLevel.ERROR;
case 'WARN': return LogLevel.WARN;
case 'INFO': return LogLevel.INFO;
case 'DEBUG': return LogLevel.DEBUG;
case 'VERBOSE': return LogLevel.VERBOSE;
default: return LogLevel.INFO;
}
}
/**
* Format timestamp in local time with milliseconds
* Shared timestamp format for consistency across logger and interaction maps
* Example: 2025-11-05 13:45:23.123
*/
export function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
}
class Logger {
private level: LogLevel;
private prefix: string;
private timestamp: boolean;
private logFile: string | null;
constructor(options: LoggerOptions = {}) {
this.level = options.level ?? getDefaultLogLevel();
this.prefix = options.prefix ?? '[browser-pilot]';
this.timestamp = options.timestamp ?? false;
this.logFile = options.logFile ?? null;
// Initialize log file if specified
if (this.logFile) {
this.initLogFile();
}
}
/**
* Initialize log file (create or clear)
*/
private initLogFile(): void {
if (!this.logFile) return;
try {
// Create directory if needed
const fs = require('fs');
const dir = dirname(this.logFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create empty log file
writeFileSync(this.logFile, `=== Browser Pilot Daemon Log ===\nStarted: ${getLocalTimestamp()}\n\n`, 'utf-8');
} catch (error) {
console.error('Failed to initialize log file:', error);
}
}
/**
* Write log message to file
*/
private writeToFile(message: string): void {
if (!this.logFile) return;
try {
appendFileSync(this.logFile, message + '\n', 'utf-8');
} catch (_error) {
// Silently fail - don't break logging
}
}
/**
* Enable file logging
*/
enableFileLogging(filePath: string): void {
this.logFile = filePath;
this.timestamp = true; // Always enable timestamp for file logging
this.initLogFile();
}
/**
* Disable file logging
*/
disableFileLogging(): void {
this.logFile = null;
}
/**
* Enable timestamp in logs
*/
enableTimestamp(): void {
this.timestamp = true;
}
/**
* Disable timestamp in logs
*/
disableTimestamp(): void {
this.timestamp = false;
}
/**
* Format timestamp in local time
* Example: 2025-11-05 13:45:23
*/
private getTimestamp(): string {
return getLocalTimestamp();
}
private formatMessage(level: string, message: string): string {
const parts: string[] = [];
if (this.timestamp) {
parts.push(`[${this.getTimestamp()}]`);
}
if (this.prefix) {
parts.push(this.prefix);
}
parts.push(`[${level}]`, message);
return parts.join(' ');
}
error(message: string, error?: unknown): void {
if (this.level >= LogLevel.ERROR) {
const formattedMsg = this.formatMessage('ERROR', message);
console.error(formattedMsg);
this.writeToFile(formattedMsg);
if (error instanceof Error) {
const errorMsg = ' ' + error.message;
console.error(errorMsg);
this.writeToFile(errorMsg);
if (this.level >= LogLevel.VERBOSE && error.stack) {
const stackMsg = ' Stack: ' + error.stack;
console.error(stackMsg);
this.writeToFile(stackMsg);
}
} else if (error) {
const errorStr = ' ' + String(error);
console.error(errorStr);
this.writeToFile(errorStr);
}
}
}
warn(message: string): void {
if (this.level >= LogLevel.WARN) {
const formatted = this.formatMessage('WARN', message);
console.warn(formatted);
this.writeToFile(formatted);
}
}
info(message: string): void {
if (this.level >= LogLevel.INFO) {
const formatted = this.formatMessage('INFO', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
debug(message: string): void {
if (this.level >= LogLevel.DEBUG) {
const formatted = this.formatMessage('DEBUG', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
verbose(message: string): void {
if (this.level >= LogLevel.VERBOSE) {
const formatted = this.formatMessage('VERBOSE', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
success(message: string): void {
if (this.level >= LogLevel.INFO) {
const formatted = this.formatMessage('✓', message);
console.log(formatted);
this.writeToFile(formatted);
}
}
setLevel(level: LogLevel): void {
this.level = level;
}
getLevel(): LogLevel {
return this.level;
}
}
// Default logger instance
export const logger = new Logger();
// Factory function for creating custom loggers
export function createLogger(options: LoggerOptions = {}): Logger {
return new Logger(options);
}

View File

@@ -0,0 +1,48 @@
/**
* Timestamp utilities for local time formatting
*/
/**
* Get ISO 8601 timestamp (recommended for logs)
* Format: 2025-11-08T23:17:47.123Z
* Example: 2025-11-08T23:17:47.123Z
*/
export function getISOTimestamp(): string {
return new Date().toISOString();
}
/**
* Get local timestamp string in format: YYYY-MM-DD HH:MM:SS
* Example: 2025-11-08 23:17:47
*/
export function getLocalTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Get local timestamp with timezone information
* Format: YYYY-MM-DD HH:MM:SS (UTC+X)
* Example: 2025-11-08 23:17:47 (UTC+9)
*/
export function getLocalTimestampWithTZ(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
// Get timezone offset in hours
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = offset >= 0 ? `+${offset}` : String(offset);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} (UTC${offsetStr})`;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"lib": ["ES2023"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}