diff --git a/package.json b/package.json index c8e4c37..87b1503 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,16 @@ "version": "0.18.0", "description": "Headless browser automation CLI for AI agents", "type": "module", - "main": "dist/daemon.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "bin", diff --git a/src/actions.ts b/src/actions.ts index 26f2408..b63e5cb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -621,23 +621,12 @@ async function handleNavigate( command: NavigateCommand, browser: BrowserManager ): Promise> { - browser.checkDomainAllowed(command.url); - - const page = browser.getPage(); - - // If headers are provided, set up scoped headers for this origin - if (command.headers && Object.keys(command.headers).length > 0) { - await browser.setScopedHeaders(command.url, command.headers); - } - - await page.goto(command.url, { - waitUntil: command.waitUntil ?? 'load', + const result = await browser.navigate(command.url, { + headers: command.headers, + waitUntil: command.waitUntil, }); - return successResponse(command.id, { - url: page.url(), - title: await page.title(), - }); + return successResponse(command.id, result); } async function handleClick(command: ClickCommand, browser: BrowserManager): Promise { @@ -1892,8 +1881,6 @@ async function handleStateLoad( } await browser.launch({ - id: command.id, - action: 'launch', headless: true, autoStateFilePath: command.path, }); diff --git a/src/browser.test.ts b/src/browser.test.ts index e0cbabf..3007cb6 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,5 +1,16 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { + describe, + it, + expect, + expectTypeOf, + beforeAll, + afterAll, + beforeEach, + afterEach, + vi, +} from 'vitest'; import { BrowserManager, getDefaultTimeout } from './browser.js'; +import type { BrowserManager as PublicBrowserManager, BrowserLaunchOptions } from './index.js'; import { executeCommand } from './actions.js'; import { chromium } from 'playwright-core'; import os from 'node:os'; @@ -40,18 +51,16 @@ describe('BrowserManager', () => { it('should be no-op when relaunching with same options', async () => { const browserInstance = browser.getBrowser(); - await browser.launch({ id: 'test', action: 'launch', headless: true }); + await browser.launch({ headless: true }); expect(browser.getBrowser()).toBe(browserInstance); }); it('should reconnect when CDP port changes', async () => { const newBrowser = new BrowserManager(); - await newBrowser.launch({ id: 'test', action: 'launch', headless: true }); + await newBrowser.launch({ headless: true }); expect(newBrowser.getBrowser()).not.toBeNull(); - await expect( - newBrowser.launch({ id: 'test', action: 'launch', cdpPort: 59999 }) - ).rejects.toThrow(); + await expect(newBrowser.launch({ cdpPort: 59999 })).rejects.toThrow(); expect(newBrowser.getBrowser()).toBeNull(); await newBrowser.close(); @@ -275,11 +284,93 @@ describe('BrowserManager', () => { expect(page.url()).toBe('https://example.com/'); }); + it('should navigate via BrowserManager API', async () => { + const result = await browser.navigate('https://example.com'); + expect(result).toEqual({ + url: 'https://example.com/', + title: 'Example Domain', + }); + }); + + it('should navigate with custom headers without throwing', async () => { + const result = await browser.navigate('https://example.com', { + headers: { 'X-Custom-Header': 'test-value' }, + }); + expect(result.url).toBe('https://example.com/'); + expect(result.title).toBe('Example Domain'); + }); + + it('should navigate with waitUntil option', async () => { + const result = await browser.navigate('https://example.com', { + waitUntil: 'domcontentloaded', + }); + expect(result.url).toBe('https://example.com/'); + }); + it('should get page title', async () => { const page = browser.getPage(); const title = await page.title(); expect(title).toBe('Example Domain'); }); + + it('should expose current URL and title via BrowserManager API', async () => { + await browser.navigate('https://example.com'); + await expect(browser.getUrl()).resolves.toBe('https://example.com/'); + await expect(browser.getTitle()).resolves.toBe('Example Domain'); + }); + }); + + describe('navigate() domain filtering', () => { + it('should block navigation to a domain outside allowedDomains', async () => { + const restricted = new BrowserManager(); + await restricted.launch({ headless: true, allowedDomains: ['example.com'] }); + try { + await expect(restricted.navigate('https://httpbin.org')).rejects.toThrow( + 'Navigation blocked' + ); + } finally { + await restricted.close(); + } + }); + + it('should allow navigation within allowedDomains', async () => { + const restricted = new BrowserManager(); + await restricted.launch({ headless: true, allowedDomains: ['example.com'] }); + try { + const result = await restricted.navigate('https://example.com'); + expect(result.url).toBe('https://example.com/'); + } finally { + await restricted.close(); + } + }); + + it('should block non-http(s) schemes regardless of allowedDomains', async () => { + const restricted = new BrowserManager(); + await restricted.launch({ headless: true, allowedDomains: ['example.com'] }); + try { + await expect(restricted.navigate('ftp://example.com')).rejects.toThrow( + 'Navigation blocked' + ); + } finally { + await restricted.close(); + } + }); + }); + + describe('navigate() public API types (issue #307)', () => { + it('BrowserManager exported from package entry has navigate method', () => { + // Compile-time proof: if this file type-checks, the public API surface is correct. + // navigate() must exist on BrowserManager — absence was the bug in #307. + expectTypeOf>().toHaveProperty('navigate'); + expectTypeOf>().toHaveProperty('launch'); + }); + + it('BrowserLaunchOptions does not require id or action', () => { + // id and action are IPC-only fields that must not leak into the public API + expectTypeOf().not.toHaveProperty('id'); + expectTypeOf().not.toHaveProperty('action'); + expectTypeOf().not.toHaveProperty('engine'); + }); }); describe('element interaction', () => { diff --git a/src/browser.ts b/src/browser.ts index fb6887e..7038dbd 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -97,6 +97,36 @@ export interface ScreencastOptions { everyNthFrame?: number; } +export interface NavigateOptions { + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + headers?: Record; +} + +export type BrowserLaunchOptions = Pick< + LaunchCommand, + | 'headless' + | 'viewport' + | 'browser' + | 'headers' + | 'executablePath' + | 'cdpPort' + | 'cdpUrl' + | 'autoConnect' + | 'extensions' + | 'profile' + | 'storageState' + | 'proxy' + | 'args' + | 'userAgent' + | 'provider' + | 'ignoreHTTPSErrors' + | 'allowFileAccess' + | 'colorScheme' + | 'downloadPath' + | 'allowedDomains' + | 'autoStateFilePath' +>; + interface TrackedRequest { url: string; method: string; @@ -458,6 +488,49 @@ export class BrowserManager { } } + /** + * Navigate the active page to a URL and return the resolved URL + title. + * If the browser is launched but all pages have been closed, a new page is + * created automatically before navigating (stale-session recovery). + */ + async navigate( + url: string, + options: NavigateOptions = {} + ): Promise<{ url: string; title: string }> { + this.checkDomainAllowed(url); + await this.ensurePage(); + + if (options.headers && Object.keys(options.headers).length > 0) { + await this.setScopedHeaders(url, options.headers); + } + + const page = this.getPage(); + await page.goto(url, { + waitUntil: options.waitUntil ?? 'load', + }); + + return { + url: page.url(), + title: await page.title(), + }; + } + + /** + * Get the active page URL. + */ + async getUrl(): Promise { + await this.ensurePage(); + return this.getPage().url(); + } + + /** + * Get the active page title. + */ + async getTitle(): Promise { + await this.ensurePage(); + return this.getPage().title(); + } + /** * Switch back to main frame */ @@ -1357,7 +1430,7 @@ export class BrowserManager { * Launch the browser with the specified options * If already launched, this is a no-op (browser stays open) */ - async launch(options: LaunchCommand): Promise { + async launch(options: BrowserLaunchOptions): Promise { // Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined); const hasExtensions = !!options.extensions?.length; diff --git a/src/daemon.ts b/src/daemon.ts index ed14735..8d7e513 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -503,8 +503,6 @@ export async function startDaemon(options?: { ? colorSchemeEnv : undefined; await manager.launch({ - id: 'auto', - action: 'launch' as const, headless: process.env.AGENT_BROWSER_HEADED !== '1' && process.env.AGENT_BROWSER_HEADED !== 'true', diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a710c2d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +export { BrowserManager, getDefaultTimeout } from './browser.js'; +export type { + BrowserLaunchOptions, + NavigateOptions, + ScreencastFrame, + ScreencastOptions, +} from './browser.js'; +export { IOSManager } from './ios-manager.js'; +export { executeCommand } from './actions.js'; +export type { Command, LaunchCommand, NavigateCommand, Response } from './types.js'; +export { + cleanupSocket, + getAppDir, + getConnectionInfo, + getPidFile, + getPortFile, + getPortForSession, + getSession, + getSocketDir, + getSocketPath, + getStreamPortFile, + isDaemonRunning, + safeWrite, + setSession, + startDaemon, +} from './daemon.js';