Initial commit
This commit is contained in:
43
skills/scripts/eslint.config.mjs
Normal file
43
skills/scripts/eslint.config.mjs
Normal 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'
|
||||
}
|
||||
}
|
||||
];
|
||||
47
skills/scripts/package.json
Normal file
47
skills/scripts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
skills/scripts/src/cdp/actions.ts
Normal file
28
skills/scripts/src/cdp/actions.ts
Normal 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';
|
||||
179
skills/scripts/src/cdp/actions/capture.ts
Normal file
179
skills/scripts/src/cdp/actions/capture.ts
Normal 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 };
|
||||
}
|
||||
122
skills/scripts/src/cdp/actions/cookies.ts
Normal file
122
skills/scripts/src/cdp/actions/cookies.ts
Normal 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 };
|
||||
}
|
||||
207
skills/scripts/src/cdp/actions/data.ts
Normal file
207
skills/scripts/src/cdp/actions/data.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
165
skills/scripts/src/cdp/actions/debugging.ts
Normal file
165
skills/scripts/src/cdp/actions/debugging.ts
Normal 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
|
||||
};
|
||||
}
|
||||
141
skills/scripts/src/cdp/actions/dialogs.ts
Normal file
141
skills/scripts/src/cdp/actions/dialogs.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
184
skills/scripts/src/cdp/actions/emulation.ts
Normal file
184
skills/scripts/src/cdp/actions/emulation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
259
skills/scripts/src/cdp/actions/forms.ts
Normal file
259
skills/scripts/src/cdp/actions/forms.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
225
skills/scripts/src/cdp/actions/helpers.ts
Normal file
225
skills/scripts/src/cdp/actions/helpers.ts
Normal 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;
|
||||
}
|
||||
106
skills/scripts/src/cdp/actions/input.ts
Normal file
106
skills/scripts/src/cdp/actions/input.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
712
skills/scripts/src/cdp/actions/interaction.ts
Normal file
712
skills/scripts/src/cdp/actions/interaction.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
217
skills/scripts/src/cdp/actions/navigation.ts
Normal file
217
skills/scripts/src/cdp/actions/navigation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
194
skills/scripts/src/cdp/actions/network.ts
Normal file
194
skills/scripts/src/cdp/actions/network.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
skills/scripts/src/cdp/actions/scroll.ts
Normal file
71
skills/scripts/src/cdp/actions/scroll.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
164
skills/scripts/src/cdp/actions/tabs.ts
Normal file
164
skills/scripts/src/cdp/actions/tabs.ts
Normal 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}`
|
||||
};
|
||||
}
|
||||
248
skills/scripts/src/cdp/actions/verify.ts
Normal file
248
skills/scripts/src/cdp/actions/verify.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
217
skills/scripts/src/cdp/actions/wait.ts
Normal file
217
skills/scripts/src/cdp/actions/wait.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
580
skills/scripts/src/cdp/browser.ts
Normal file
580
skills/scripts/src/cdp/browser.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
144
skills/scripts/src/cdp/client.ts
Normal file
144
skills/scripts/src/cdp/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
329
skills/scripts/src/cdp/config.ts
Normal file
329
skills/scripts/src/cdp/config.ts
Normal 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}`);
|
||||
}
|
||||
309
skills/scripts/src/cdp/map/generate-interaction-map.ts
Normal file
309
skills/scripts/src/cdp/map/generate-interaction-map.ts
Normal 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;
|
||||
})()
|
||||
`;
|
||||
}
|
||||
386
skills/scripts/src/cdp/map/query-map.ts
Normal file
386
skills/scripts/src/cdp/map/query-map.ts
Normal 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);
|
||||
}
|
||||
179
skills/scripts/src/cdp/utils.ts
Normal file
179
skills/scripts/src/cdp/utils.ts
Normal 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));
|
||||
}
|
||||
58
skills/scripts/src/cli/cli.ts
Normal file
58
skills/scripts/src/cli/cli.ts
Normal 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();
|
||||
30
skills/scripts/src/cli/commands/accessibility.ts
Normal file
30
skills/scripts/src/cli/commands/accessibility.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
237
skills/scripts/src/cli/commands/capture.ts
Normal file
237
skills/scripts/src/cli/commands/capture.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
438
skills/scripts/src/cli/commands/chain.ts
Normal file
438
skills/scripts/src/cli/commands/chain.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
230
skills/scripts/src/cli/commands/console.ts
Normal file
230
skills/scripts/src/cli/commands/console.ts
Normal 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}`;
|
||||
}
|
||||
92
skills/scripts/src/cli/commands/cookies.ts
Normal file
92
skills/scripts/src/cli/commands/cookies.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
137
skills/scripts/src/cli/commands/daemon.ts
Normal file
137
skills/scripts/src/cli/commands/daemon.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
160
skills/scripts/src/cli/commands/data.ts
Normal file
160
skills/scripts/src/cli/commands/data.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
29
skills/scripts/src/cli/commands/dialogs.ts
Normal file
29
skills/scripts/src/cli/commands/dialogs.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
28
skills/scripts/src/cli/commands/emulation.ts
Normal file
28
skills/scripts/src/cli/commands/emulation.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
53
skills/scripts/src/cli/commands/focus.ts
Normal file
53
skills/scripts/src/cli/commands/focus.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
81
skills/scripts/src/cli/commands/forms.ts
Normal file
81
skills/scripts/src/cli/commands/forms.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
243
skills/scripts/src/cli/commands/interaction.ts
Normal file
243
skills/scripts/src/cli/commands/interaction.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
104
skills/scripts/src/cli/commands/navigation.ts
Normal file
104
skills/scripts/src/cli/commands/navigation.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
77
skills/scripts/src/cli/commands/network.ts
Normal file
77
skills/scripts/src/cli/commands/network.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
199
skills/scripts/src/cli/commands/query.ts
Normal file
199
skills/scripts/src/cli/commands/query.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
34
skills/scripts/src/cli/commands/scroll.ts
Normal file
34
skills/scripts/src/cli/commands/scroll.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
71
skills/scripts/src/cli/commands/selector-helper.ts
Normal file
71
skills/scripts/src/cli/commands/selector-helper.ts
Normal 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;
|
||||
}
|
||||
222
skills/scripts/src/cli/commands/system.ts
Normal file
222
skills/scripts/src/cli/commands/system.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
118
skills/scripts/src/cli/commands/tabs.ts
Normal file
118
skills/scripts/src/cli/commands/tabs.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
62
skills/scripts/src/cli/commands/wait.ts
Normal file
62
skills/scripts/src/cli/commands/wait.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
68
skills/scripts/src/cli/daemon-helper.ts
Normal file
68
skills/scripts/src/cli/daemon-helper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
208
skills/scripts/src/constants/index.ts
Normal file
208
skills/scripts/src/constants/index.ts
Normal 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;
|
||||
212
skills/scripts/src/daemon/client.ts
Normal file
212
skills/scripts/src/daemon/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
83
skills/scripts/src/daemon/handlers/capture-handlers.ts
Normal file
83
skills/scripts/src/daemon/handlers/capture-handlers.ts
Normal 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);
|
||||
}
|
||||
56
skills/scripts/src/daemon/handlers/data-handlers.ts
Normal file
56
skills/scripts/src/daemon/handlers/data-handlers.ts
Normal 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);
|
||||
}
|
||||
55
skills/scripts/src/daemon/handlers/index.ts
Normal file
55
skills/scripts/src/daemon/handlers/index.ts
Normal 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';
|
||||
293
skills/scripts/src/daemon/handlers/interaction-handlers.ts
Normal file
293
skills/scripts/src/daemon/handlers/interaction-handlers.ts
Normal 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);
|
||||
}
|
||||
227
skills/scripts/src/daemon/handlers/map-handlers.ts
Normal file
227
skills/scripts/src/daemon/handlers/map-handlers.ts
Normal 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);
|
||||
}
|
||||
184
skills/scripts/src/daemon/handlers/navigation-handlers.ts
Normal file
184
skills/scripts/src/daemon/handlers/navigation-handlers.ts
Normal 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;
|
||||
}
|
||||
79
skills/scripts/src/daemon/handlers/utility-handlers.ts
Normal file
79
skills/scripts/src/daemon/handlers/utility-handlers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
596
skills/scripts/src/daemon/manager.ts
Normal file
596
skills/scripts/src/daemon/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
527
skills/scripts/src/daemon/map-manager.ts
Normal file
527
skills/scripts/src/daemon/map-manager.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
265
skills/scripts/src/daemon/protocol.ts
Normal file
265
skills/scripts/src/daemon/protocol.ts
Normal 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];
|
||||
880
skills/scripts/src/daemon/server.ts
Normal file
880
skills/scripts/src/daemon/server.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
242
skills/scripts/src/utils/logger.ts
Normal file
242
skills/scripts/src/utils/logger.ts
Normal 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);
|
||||
}
|
||||
48
skills/scripts/src/utils/timestamp.ts
Normal file
48
skills/scripts/src/utils/timestamp.ts
Normal 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})`;
|
||||
}
|
||||
21
skills/scripts/tsconfig.json
Normal file
21
skills/scripts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user