Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4f969219 | ||
|
|
7b43d408da | ||
|
|
2dc093cd62 | ||
|
|
673e2e266e |
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.4"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser"
|
name = "agent-browser"
|
||||||
version = "0.4.4"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -901,6 +901,7 @@ mod tests {
|
|||||||
debug: false,
|
debug: false,
|
||||||
headers: None,
|
headers: None,
|
||||||
executable_path: None,
|
executable_path: None,
|
||||||
|
extensions: Vec::new(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -159,9 +159,16 @@ pub struct DaemonResult {
|
|||||||
pub already_running: bool,
|
pub already_running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>) -> Result<DaemonResult, String> {
|
pub fn ensure_daemon(
|
||||||
|
session: &str,
|
||||||
|
headed: bool,
|
||||||
|
executable_path: Option<&str>,
|
||||||
|
extensions: &[String],
|
||||||
|
) -> Result<DaemonResult, String> {
|
||||||
if is_daemon_running(session) && daemon_ready(session) {
|
if is_daemon_running(session) && daemon_ready(session) {
|
||||||
return Ok(DaemonResult { already_running: true });
|
return Ok(DaemonResult {
|
||||||
|
already_running: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||||
@@ -196,6 +203,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
|
|||||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !extensions.is_empty() {
|
||||||
|
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
// Create new process group and session to fully detach
|
// Create new process group and session to fully detach
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(|| {
|
cmd.pre_exec(|| {
|
||||||
@@ -234,6 +245,10 @@ pub fn ensure_daemon(session: &str, headed: bool, executable_path: Option<&str>)
|
|||||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !extensions.is_empty() {
|
||||||
|
cmd.env("AGENT_BROWSER_EXTENSIONS", extensions.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||||
|
|||||||
+15
-2
@@ -9,9 +9,15 @@ pub struct Flags {
|
|||||||
pub headers: Option<String>,
|
pub headers: Option<String>,
|
||||||
pub executable_path: Option<String>,
|
pub executable_path: Option<String>,
|
||||||
pub cdp: Option<String>,
|
pub cdp: Option<String>,
|
||||||
|
pub extensions: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_flags(args: &[String]) -> Flags {
|
pub fn parse_flags(args: &[String]) -> Flags {
|
||||||
|
let extensions_env = env::var("AGENT_BROWSER_EXTENSIONS")
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut flags = Flags {
|
let mut flags = Flags {
|
||||||
json: false,
|
json: false,
|
||||||
full: false,
|
full: false,
|
||||||
@@ -21,6 +27,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
headers: None,
|
headers: None,
|
||||||
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
|
||||||
cdp: None,
|
cdp: None,
|
||||||
|
extensions: extensions_env,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
@@ -47,7 +54,13 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
flags.executable_path = Some(s.clone());
|
flags.executable_path = Some(s.clone());
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"--extension" => {
|
||||||
|
if let Some(s) = args.get(i + 1) {
|
||||||
|
flags.extensions.push(s.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
"--cdp" => {
|
"--cdp" => {
|
||||||
if let Some(s) = args.get(i + 1) {
|
if let Some(s) = args.get(i + 1) {
|
||||||
flags.cdp = Some(s.clone());
|
flags.cdp = Some(s.clone());
|
||||||
@@ -68,7 +81,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
|||||||
// Global flags that should be stripped from command args
|
// Global flags that should be stripped from command args
|
||||||
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
const GLOBAL_FLAGS: &[&str] = &["--json", "--full", "--headed", "--debug"];
|
||||||
// Global flags that take a value (need to skip the next arg too)
|
// Global flags that take a value (need to skip the next arg too)
|
||||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp"];
|
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &["--session", "--headers", "--executable-path", "--cdp", "--extension"];
|
||||||
|
|
||||||
for arg in args.iter() {
|
for arg in args.iter() {
|
||||||
if skip_next {
|
if skip_next {
|
||||||
|
|||||||
+8
-3
@@ -149,7 +149,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref()) {
|
let daemon_result = match ensure_daemon(&flags.session, flags.headed, flags.executable_path.as_deref(), &flags.extensions) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if flags.json {
|
if flags.json {
|
||||||
@@ -162,9 +162,14 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Warn if executable_path was specified but daemon was already running
|
// Warn if executable_path was specified but daemon was already running
|
||||||
if daemon_result.already_running && flags.executable_path.is_some() {
|
if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) {
|
||||||
if !flags.json {
|
if !flags.json {
|
||||||
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
|
if flags.executable_path.is_some() {
|
||||||
|
eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.");
|
||||||
|
}
|
||||||
|
if !flags.extensions.is_empty() {
|
||||||
|
eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1191,6 +1191,7 @@ Options:
|
|||||||
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
--session <name> Isolated session (or AGENT_BROWSER_SESSION env)
|
||||||
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
--headers <json> HTTP headers scoped to URL's origin (for auth)
|
||||||
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
--executable-path <path> Custom browser executable (or AGENT_BROWSER_EXECUTABLE_PATH)
|
||||||
|
--extension <path> Load browser extensions (repeatable).
|
||||||
--json JSON output
|
--json JSON output
|
||||||
--full, -f Full page screenshot
|
--full, -f Full page screenshot
|
||||||
--headed Show browser window (not headless)
|
--headed Show browser window (not headless)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser",
|
"name": "agent-browser",
|
||||||
"version": "0.4.4",
|
"version": "0.5.0",
|
||||||
"description": "Headless browser automation CLI for AI agents",
|
"description": "Headless browser automation CLI for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/daemon.js",
|
"main": "dist/daemon.js",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toAIFriendlyError } from './actions.js';
|
||||||
|
|
||||||
|
describe('toAIFriendlyError', () => {
|
||||||
|
describe('element blocked by overlay', () => {
|
||||||
|
it('should detect intercepts pointer events even when Timeout is in message', () => {
|
||||||
|
// This is the exact error from Playwright when a cookie banner blocks an element
|
||||||
|
// Bug: Previously this was incorrectly reported as "not found or not visible"
|
||||||
|
const error = new Error(
|
||||||
|
'TimeoutError: locator.click: Timeout 10000ms exceeded.\n' +
|
||||||
|
'Call log:\n' +
|
||||||
|
" - waiting for getByRole('link', { name: 'Anmelden', exact: true }).first()\n" +
|
||||||
|
' - locator resolved to <a href="https://example.com/login">Anmelden</a>\n' +
|
||||||
|
' - attempting click action\n' +
|
||||||
|
' 2 x waiting for element to be visible, enabled and stable\n' +
|
||||||
|
' - element is visible, enabled and stable\n' +
|
||||||
|
' - scrolling into view if needed\n' +
|
||||||
|
' - done scrolling\n' +
|
||||||
|
' - <body class="font-sans antialiased">...</body> intercepts pointer events\n' +
|
||||||
|
' - retrying click action'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = toAIFriendlyError(error, '@e4');
|
||||||
|
|
||||||
|
// Must NOT say "not found" - the element WAS found
|
||||||
|
expect(result.message).not.toContain('not found');
|
||||||
|
// Must indicate the element is blocked
|
||||||
|
expect(result.message).toContain('blocked by another element');
|
||||||
|
expect(result.message).toContain('modal or overlay');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest dismissing cookie banners', () => {
|
||||||
|
const error = new Error('<div class="cookie-overlay"> intercepts pointer events');
|
||||||
|
const result = toAIFriendlyError(error, '@e1');
|
||||||
|
|
||||||
|
expect(result.message).toContain('cookie banners');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-10
@@ -134,8 +134,9 @@ interface SnapshotData {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Playwright errors to AI-friendly messages
|
* Convert Playwright errors to AI-friendly messages
|
||||||
|
* @internal Exported for testing
|
||||||
*/
|
*/
|
||||||
function toAIFriendlyError(error: unknown, selector: string): Error {
|
export function toAIFriendlyError(error: unknown, selector: string): Error {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
// Handle strict mode violation (multiple elements match)
|
// Handle strict mode violation (multiple elements match)
|
||||||
@@ -150,7 +151,24 @@ function toAIFriendlyError(error: unknown, selector: string): Error {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle element not found
|
// Handle element not interactable (must be checked BEFORE timeout case)
|
||||||
|
// This includes cases where an overlay/modal blocks the element
|
||||||
|
if (message.includes('intercepts pointer events')) {
|
||||||
|
return new Error(
|
||||||
|
`Element "${selector}" is blocked by another element (likely a modal or overlay). ` +
|
||||||
|
`Try dismissing any modals/cookie banners first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle element not visible
|
||||||
|
if (message.includes('not visible') && !message.includes('Timeout')) {
|
||||||
|
return new Error(
|
||||||
|
`Element "${selector}" is not visible. ` +
|
||||||
|
`Try scrolling it into view or check if it's hidden.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle element not found (timeout waiting for element)
|
||||||
if (
|
if (
|
||||||
message.includes('waiting for') &&
|
message.includes('waiting for') &&
|
||||||
(message.includes('to be visible') || message.includes('Timeout'))
|
(message.includes('to be visible') || message.includes('Timeout'))
|
||||||
@@ -161,14 +179,6 @@ function toAIFriendlyError(error: unknown, selector: string): Error {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle element not interactable
|
|
||||||
if (message.includes('intercepts pointer events') || message.includes('not visible')) {
|
|
||||||
return new Error(
|
|
||||||
`Element "${selector}" is not interactable (may be hidden or covered). ` +
|
|
||||||
`Try scrolling it into view or check if a modal/overlay is blocking it.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return original error for unknown cases
|
// Return original error for unknown cases
|
||||||
return error instanceof Error ? error : new Error(message);
|
return error instanceof Error ? error : new Error(message);
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-24
@@ -13,6 +13,8 @@ import {
|
|||||||
type Locator,
|
type Locator,
|
||||||
type CDPSession,
|
type CDPSession,
|
||||||
} from 'playwright-core';
|
} from 'playwright-core';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
import type { LaunchCommand } from './types.js';
|
import type { LaunchCommand } from './types.js';
|
||||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ interface PageError {
|
|||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private cdpPort: number | null = null;
|
private cdpPort: number | null = null;
|
||||||
|
private isPersistentContext: boolean = false;
|
||||||
private contexts: BrowserContext[] = [];
|
private contexts: BrowserContext[] = [];
|
||||||
private pages: Page[] = [];
|
private pages: Page[] = [];
|
||||||
private activePageIndex: number = 0;
|
private activePageIndex: number = 0;
|
||||||
@@ -90,7 +93,7 @@ export class BrowserManager {
|
|||||||
* Check if browser is launched
|
* Check if browser is launched
|
||||||
*/
|
*/
|
||||||
isLaunched(): boolean {
|
isLaunched(): boolean {
|
||||||
return this.browser !== null;
|
return this.browser !== null || this.isPersistentContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -637,12 +640,16 @@ export class BrowserManager {
|
|||||||
*/
|
*/
|
||||||
async launch(options: LaunchCommand): Promise<void> {
|
async launch(options: LaunchCommand): Promise<void> {
|
||||||
const cdpPort = options.cdpPort;
|
const cdpPort = options.cdpPort;
|
||||||
|
const hasExtensions = !!options.extensions?.length;
|
||||||
|
|
||||||
if (this.browser) {
|
if (hasExtensions && cdpPort) {
|
||||||
const switchingFromCdpToBrowser = !cdpPort && this.cdpPort !== null;
|
throw new Error('Extensions cannot be used with CDP connection');
|
||||||
const needsCdpReconnect = !!cdpPort && this.needsCdpReconnect(cdpPort);
|
}
|
||||||
|
|
||||||
if (switchingFromCdpToBrowser || needsCdpReconnect) {
|
if (this.isLaunched()) {
|
||||||
|
const needsRelaunch =
|
||||||
|
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
|
||||||
|
if (needsRelaunch) {
|
||||||
await this.close();
|
await this.close();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
@@ -654,35 +661,45 @@ export class BrowserManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select browser type
|
|
||||||
const browserType = options.browser ?? 'chromium';
|
const browserType = options.browser ?? 'chromium';
|
||||||
|
if (hasExtensions && browserType !== 'chromium') {
|
||||||
|
throw new Error('Extensions are only supported in Chromium');
|
||||||
|
}
|
||||||
|
|
||||||
const launcher =
|
const launcher =
|
||||||
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
||||||
|
const viewport = options.viewport ?? { width: 1280, height: 720 };
|
||||||
|
|
||||||
// Launch browser
|
let context: BrowserContext;
|
||||||
this.browser = await launcher.launch({
|
if (hasExtensions) {
|
||||||
headless: options.headless ?? true,
|
const extPaths = options.extensions!.join(',');
|
||||||
executablePath: options.executablePath,
|
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||||
});
|
context = await launcher.launchPersistentContext(
|
||||||
this.cdpPort = null;
|
path.join(os.tmpdir(), `agent-browser-ext-${session}`),
|
||||||
|
{
|
||||||
|
headless: false,
|
||||||
|
executablePath: options.executablePath,
|
||||||
|
args: [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`],
|
||||||
|
viewport,
|
||||||
|
extraHTTPHeaders: options.headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.isPersistentContext = true;
|
||||||
|
} else {
|
||||||
|
this.browser = await launcher.launch({
|
||||||
|
headless: options.headless ?? true,
|
||||||
|
executablePath: options.executablePath,
|
||||||
|
});
|
||||||
|
this.cdpPort = null;
|
||||||
|
context = await this.browser.newContext({ viewport, extraHTTPHeaders: options.headers });
|
||||||
|
}
|
||||||
|
|
||||||
// Create context with viewport and optional headers
|
|
||||||
const context = await this.browser.newContext({
|
|
||||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
|
||||||
extraHTTPHeaders: options.headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set default timeout to 10 seconds (Playwright default is 30s)
|
|
||||||
context.setDefaultTimeout(10000);
|
context.setDefaultTimeout(10000);
|
||||||
|
|
||||||
this.contexts.push(context);
|
this.contexts.push(context);
|
||||||
|
|
||||||
// Create initial page
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
const page = await context.newPage();
|
|
||||||
this.pages.push(page);
|
this.pages.push(page);
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
|
|
||||||
// Automatically start console and error tracking
|
|
||||||
this.setupPageTracking(page);
|
this.setupPageTracking(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,6 +1141,7 @@ export class BrowserManager {
|
|||||||
this.pages = [];
|
this.pages = [];
|
||||||
this.contexts = [];
|
this.contexts = [];
|
||||||
this.cdpPort = null;
|
this.cdpPort = null;
|
||||||
|
this.isPersistentContext = false;
|
||||||
this.activePageIndex = 0;
|
this.activePageIndex = 0;
|
||||||
this.refMap = {};
|
this.refMap = {};
|
||||||
this.lastSnapshot = '';
|
this.lastSnapshot = '';
|
||||||
|
|||||||
@@ -192,11 +192,17 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
|
|||||||
parseResult.command.action !== 'launch' &&
|
parseResult.command.action !== 'launch' &&
|
||||||
parseResult.command.action !== 'close'
|
parseResult.command.action !== 'close'
|
||||||
) {
|
) {
|
||||||
|
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
|
||||||
|
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
await browser.launch({
|
await browser.launch({
|
||||||
id: 'auto',
|
id: 'auto',
|
||||||
action: 'launch',
|
action: 'launch',
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
||||||
|
extensions: extensions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
|
extensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigateCommand extends BaseCommand {
|
export interface NavigateCommand extends BaseCommand {
|
||||||
|
|||||||
Reference in New Issue
Block a user