feat(cdp): 默认优先连接 9333 常驻 Chrome
- 无显式连接参数时先尝试 CDP 9333,失败后回退本地浏览器启动 - 修复 CDP 页选择稳定性:过滤 omnibox 系统页、无可用页时自动创建 fallback 页 - 调整页面关闭后的 active 索引维护,降低 No page found 问题 - 新增 agent-browser-stealth 二进制入口并保持与 agent-browser 行为一致 - 同步更新 CLI 帮助、README、技能文档与 docs 说明
This commit is contained in:
+66
-6
@@ -69,7 +69,7 @@ describe('BrowserManager', () => {
|
||||
|
||||
it('should apply init-script stealth policy for CDP connections', async () => {
|
||||
const addInitScript = vi.fn().mockResolvedValue(undefined);
|
||||
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
|
||||
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
|
||||
const mockContext = {
|
||||
pages: () => [mockPage],
|
||||
on: vi.fn(),
|
||||
@@ -99,7 +99,7 @@ describe('BrowserManager', () => {
|
||||
|
||||
it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => {
|
||||
const addInitScript = vi.fn().mockResolvedValue(undefined);
|
||||
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
|
||||
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
|
||||
const mockContext = {
|
||||
pages: () => [mockPage],
|
||||
on: vi.fn(),
|
||||
@@ -926,15 +926,16 @@ describe('BrowserManager', () => {
|
||||
contexts: () => [
|
||||
{
|
||||
pages: () => [
|
||||
{ url: () => 'http://example.com', on: vi.fn() },
|
||||
{ url: () => '', on: vi.fn() }, // This page should be filtered out
|
||||
{ url: () => 'http://anothersite.com', on: vi.fn() },
|
||||
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
|
||||
{ url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out
|
||||
{ url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false },
|
||||
],
|
||||
on: vi.fn(),
|
||||
setDefaultTimeout: vi.fn(),
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
],
|
||||
close: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
@@ -950,6 +951,65 @@ describe('BrowserManager', () => {
|
||||
expect(urls).toContain('http://example.com');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should ignore omnibox popup pages during CDP connection', async () => {
|
||||
const mockBrowser = {
|
||||
contexts: () => [
|
||||
{
|
||||
pages: () => [
|
||||
{
|
||||
url: () => 'chrome://omnibox-popup.top-chrome/',
|
||||
on: vi.fn(),
|
||||
isClosed: () => false,
|
||||
},
|
||||
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
|
||||
],
|
||||
on: vi.fn(),
|
||||
setDefaultTimeout: vi.fn(),
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
],
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222 });
|
||||
|
||||
expect(cdpBrowser.getPages().length).toBe(1);
|
||||
expect(cdpBrowser.getPages()[0]?.url()).toBe('http://example.com');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create a fallback page when CDP has only internal pages', async () => {
|
||||
const newPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
|
||||
const context = {
|
||||
pages: () => [
|
||||
{
|
||||
url: () => 'chrome://omnibox-popup.top-chrome/omnibox_popup_aim.html',
|
||||
on: vi.fn(),
|
||||
isClosed: () => false,
|
||||
},
|
||||
],
|
||||
newPage: vi.fn().mockResolvedValue(newPage),
|
||||
on: vi.fn(),
|
||||
setDefaultTimeout: vi.fn(),
|
||||
addInitScript: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockBrowser = {
|
||||
contexts: () => [context],
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
|
||||
|
||||
const cdpBrowser = new BrowserManager();
|
||||
await cdpBrowser.launch({ cdpPort: 9222 });
|
||||
|
||||
expect(context.newPage).toHaveBeenCalledTimes(1);
|
||||
expect(cdpBrowser.getPages().length).toBe(1);
|
||||
expect(cdpBrowser.getPages()[0]?.url()).toBe('about:blank');
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('screencast', () => {
|
||||
|
||||
+82
-6
@@ -125,6 +125,8 @@ interface StealthContextDefaults {
|
||||
extraHTTPHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
*/
|
||||
@@ -483,6 +485,33 @@ export class BrowserManager {
|
||||
return this.pages.length > 0;
|
||||
}
|
||||
|
||||
private getSafePageUrl(page: Page): string {
|
||||
try {
|
||||
return page.url();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private isIgnoredCDPPageUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
const normalizedUrl = url.toLowerCase();
|
||||
return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix));
|
||||
}
|
||||
|
||||
private isUsableCDPPage(page: Page): boolean {
|
||||
if (page.isClosed()) return false;
|
||||
const url = this.getSafePageUrl(page);
|
||||
if (!url) return false;
|
||||
return !this.isIgnoredCDPPageUrl(url);
|
||||
}
|
||||
|
||||
private collectUsableCDPPages(contexts: BrowserContext[]): Page[] {
|
||||
return contexts
|
||||
.flatMap((context) => context.pages())
|
||||
.filter((page) => this.isUsableCDPPage(page));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure at least one page exists. If the browser is launched but all pages
|
||||
* were closed (stale session), creates a new page on the existing context.
|
||||
@@ -527,6 +556,24 @@ export class BrowserManager {
|
||||
if (this.pages.length === 0) {
|
||||
throw new Error('Browser not launched. Call launch first.');
|
||||
}
|
||||
|
||||
const current = this.pages[this.activePageIndex];
|
||||
if (current && this.isUsableCDPPage(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page));
|
||||
if (usableIndex !== -1) {
|
||||
this.activePageIndex = usableIndex;
|
||||
return this.pages[this.activePageIndex];
|
||||
}
|
||||
|
||||
const openIndex = this.pages.findIndex((page) => !page.isClosed());
|
||||
if (openIndex !== -1) {
|
||||
this.activePageIndex = openIndex;
|
||||
return this.pages[this.activePageIndex];
|
||||
}
|
||||
|
||||
return this.pages[this.activePageIndex];
|
||||
}
|
||||
|
||||
@@ -1008,7 +1055,7 @@ export class BrowserManager {
|
||||
try {
|
||||
const contexts = this.browser.contexts();
|
||||
if (contexts.length === 0) return false;
|
||||
return contexts.some((context) => context.pages().length > 0);
|
||||
return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -1722,11 +1769,32 @@ export class BrowserManager {
|
||||
throw new Error('No browser context found. Make sure the app has an open window.');
|
||||
}
|
||||
|
||||
// Filter out pages with empty URLs, which can cause Playwright to hang
|
||||
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
|
||||
let allPages = this.collectUsableCDPPages(contexts);
|
||||
|
||||
if (allPages.length === 0) {
|
||||
throw new Error('No page found. Make sure the app has loaded content.');
|
||||
// Some Chrome instances (especially with custom UI pages) expose only internal/transient
|
||||
// pages over CDP. Create a fresh page so commands always have a stable target.
|
||||
let fallbackPage: Page | null = null;
|
||||
for (const context of contexts) {
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
if (!fallbackPage) {
|
||||
fallbackPage = page;
|
||||
}
|
||||
if (this.isUsableCDPPage(page)) {
|
||||
fallbackPage = page;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Try next context
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackPage) {
|
||||
throw new Error('No page found. Make sure the app has loaded content.');
|
||||
}
|
||||
|
||||
allPages = [fallbackPage];
|
||||
}
|
||||
|
||||
// All validation passed - commit state
|
||||
@@ -1831,7 +1899,7 @@ export class BrowserManager {
|
||||
* Discovery strategy:
|
||||
* 1. Read DevToolsActivePort from Chrome's default user data directories
|
||||
* 2. If found, connect using the port and WebSocket path from that file
|
||||
* 3. If not found, probe common debugging ports (9222, 9229)
|
||||
* 3. If not found, probe common debugging ports (9222, 9229, 9333)
|
||||
* 4. If a port responds, connect via CDP
|
||||
*/
|
||||
private async autoConnectViaCDP(): Promise<void> {
|
||||
@@ -1866,7 +1934,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
// Strategy 2: Probe common debugging ports
|
||||
const commonPorts = [9222, 9229];
|
||||
const commonPorts = [9222, 9229, 9333];
|
||||
for (const port of commonPorts) {
|
||||
const wsUrl = await this.probeDebugPort(port);
|
||||
if (wsUrl) {
|
||||
@@ -1922,6 +1990,9 @@ export class BrowserManager {
|
||||
const index = this.pages.indexOf(page);
|
||||
if (index !== -1) {
|
||||
this.pages.splice(index, 1);
|
||||
if (index < this.activePageIndex) {
|
||||
this.activePageIndex--;
|
||||
}
|
||||
if (this.activePageIndex >= this.pages.length) {
|
||||
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
||||
}
|
||||
@@ -1935,6 +2006,11 @@ export class BrowserManager {
|
||||
*/
|
||||
private setupContextTracking(context: BrowserContext): void {
|
||||
context.on('page', (page) => {
|
||||
const pageUrl = this.getSafePageUrl(page);
|
||||
if (this.isIgnoredCDPPageUrl(pageUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add if not already tracked (avoids duplicates when newTab() creates pages)
|
||||
if (!this.pages.includes(page)) {
|
||||
this.pages.push(page);
|
||||
|
||||
+38
-4
@@ -405,7 +405,9 @@ export async function startDaemon(options?: {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command
|
||||
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
|
||||
// Default behavior for this fork: first try attaching to a resident Chrome on CDP :9333,
|
||||
// then fall back to launching a local Playwright browser if CDP is unavailable.
|
||||
if (
|
||||
!manager.isLaunched() &&
|
||||
parseResult.command.action !== 'launch' &&
|
||||
@@ -452,13 +454,13 @@ export async function startDaemon(options?: {
|
||||
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
|
||||
// Stealth is always enabled in agent-browser-stealth
|
||||
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
|
||||
const colorScheme =
|
||||
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
|
||||
colorSchemeEnv === 'dark' ||
|
||||
colorSchemeEnv === 'light' ||
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
await manager.launch({
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
headless: process.env.AGENT_BROWSER_HEADED !== '1',
|
||||
@@ -474,7 +476,39 @@ export async function startDaemon(options?: {
|
||||
|
||||
colorScheme,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
});
|
||||
};
|
||||
|
||||
let launchedViaDefaultCdp = false;
|
||||
try {
|
||||
// Keep default CDP attempt minimal. Launch-only options like profile/extensions
|
||||
// are incompatible with CDP and can cause a false-negative fallback.
|
||||
const cdpLaunchOptions = {
|
||||
id: launchOptions.id,
|
||||
action: launchOptions.action,
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
});
|
||||
launchedViaDefaultCdp = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[DEBUG] Default CDP port 9333 unavailable, falling back to local launch: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!launchedViaDefaultCdp) {
|
||||
await manager.launch(launchOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user