diff --git a/src/context.ts b/src/context.ts index 943e290..88c8fd1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { fork } from 'child_process'; +import path from 'path'; + import * as playwright from 'playwright'; export type ContextOptions = { @@ -69,6 +72,25 @@ export class Context { this._console.length = 0; } + async install(): Promise { + const channel = this._options.launchOptions?.channel || 'chrome'; + const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js'); + const child = fork(cli, ['install', channel], { + stdio: 'pipe', + }); + const output: string[] = []; + child.stdout?.on('data', data => output.push(data.toString())); + child.stderr?.on('data', data => output.push(data.toString())); + return new Promise((resolve, reject) => { + child.on('close', code => { + if (code === 0) + resolve(channel); + else + reject(new Error(`Failed to install browser: ${output.join('')}`)); + }); + }); + } + existingPage(): playwright.Page { if (!this._page) throw new Error('Navigate to a location to create a page'); @@ -119,11 +141,21 @@ export class Context { return { browser, page }; } - const context = await playwright.chromium.launchPersistentContext(this._options.userDataDir, this._options.launchOptions); + const context = await this._launchPersistentContext(); const [page] = context.pages(); return { page }; } + private async _launchPersistentContext(): Promise { + try { + return await playwright.chromium.launchPersistentContext(this._options.userDataDir, this._options.launchOptions); + } catch (error: any) { + if (error.message.includes('Executable doesn\'t exist')) + throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); + throw error; + } + } + async allFramesSnapshot() { const page = this.existingPage(); const visibleFrames = await page.locator('iframe').filter({ visible: true }).all(); diff --git a/src/index.ts b/src/index.ts index d43a359..e88f298 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ const commonTools: Tool[] = [ common.wait, common.pdf, common.close, + common.install, ]; const snapshotTools: Tool[] = [ diff --git a/src/program.ts b/src/program.ts index c196106..71c8bf5 100644 --- a/src/program.ts +++ b/src/program.ts @@ -40,10 +40,13 @@ program .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--port ', 'Port to listen on for SSE transport.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') + .option('--channel ', 'Channel to use for browser, possible values: chrome, msedge, chromium. Default: chrome') + .option('--executable-path ', 'Path to the browser executable.') .action(async options => { const launchOptions: LaunchOptions = { headless: !!options.headless, - channel: 'chrome', + channel: options.channel ?? 'chrome', + executablePath: options.executablePath, }; const userDataDir = options.userDataDir ?? await createUserDataDir(); const serverList = new ServerList(() => createServer({ diff --git a/src/tools/common.ts b/src/tools/common.ts index 71583a5..6ef52e2 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -174,3 +174,20 @@ export const chooseFile: ToolFactory = snapshot => ({ }, snapshot); }, }); + +export const install: Tool = { + schema: { + name: 'browser_install', + description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', + inputSchema: zodToJsonSchema(z.object({})), + }, + handle: async context => { + const channel = await context.install(); + return { + content: [{ + type: 'text', + text: `Browser ${channel} installed`, + }], + }; + }, +}; diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts index 5a04cca..38b9970 100644 --- a/tests/basic.spec.ts +++ b/tests/basic.spec.ts @@ -36,6 +36,7 @@ test('test tool list', async ({ client, visionClient }) => { 'browser_wait', 'browser_save_as_pdf', 'browser_close', + 'browser_install', ]); const { tools: visionTools } = await visionClient.listTools(); @@ -53,6 +54,7 @@ test('test tool list', async ({ client, visionClient }) => { 'browser_wait', 'browser_save_as_pdf', 'browser_close', + 'browser_install', ]); }); @@ -353,3 +355,14 @@ test('save as pdf', async ({ client }) => { }); expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/); }); + +test('executable path', async ({ startClient }) => { + const client = await startClient({ args: [`--executable-path=bogus`] }); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + }); + expect(response).toContainTextContent(`executable doesn't exist`); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index c9c8932..5cb48ae 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -71,6 +71,7 @@ export const test = baseTest.extend({ cdpEndpoint: async ({ }, use, testInfo) => { const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!); const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), { + channel: 'chrome', args: [`--remote-debugging-port=${port}`], }); await use(`http://localhost:${port}`);