From 6ae3991efb298596a81120f114fe7338ca331436 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 20 Apr 2026 01:17:28 -0600 Subject: [PATCH] feat: auto-install Playwright browsers on launch failure When the browser binary is missing (fresh install, first run), detect the error and run `playwright install ` automatically instead of erroring out. On Linux with root, also attempts `install-deps`. - New src/browserInstaller.ts module with install deduping and caching - Wired into isolated and persistent context launch paths - Wired into video-recording launch path in context.ts - Clear error if playwright npm package itself isn't resolvable --- src/browserContextFactory.ts | 37 ++++++++--- src/browserInstaller.ts | 118 +++++++++++++++++++++++++++++++++++ src/context.ts | 5 +- 3 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 src/browserInstaller.ts diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 4bc1b34..1904ec5 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -22,6 +22,7 @@ import os from 'node:os'; import * as playwright from 'playwright'; import { logUnhandledError, testDebug } from './log.js'; +import { installBrowser, isMissingBrowserError } from './browserInstaller.js'; import type { FullConfig } from './config.js'; @@ -136,11 +137,7 @@ class IsolatedContextFactory extends BaseContextFactory { ]; } - return browserType.launch(launchOptions).catch(error => { - 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; - }); + return launchWithAutoInstall(this.browserConfig, launchOptions, browserType); } protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise { @@ -217,14 +214,19 @@ class PersistentContextFactory implements BrowserContextFactory { } const browserType = playwright[this.browserConfig.browserName]; + let didAutoInstall = false; for (let i = 0; i < 5; i++) { try { const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); const close = () => this._closeBrowserContext(browserContext, userDataDir); return { browserContext, close }; } 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.`); + if (!didAutoInstall && isMissingBrowserError(error)) { + testDebug('browser missing, attempting auto-install (persistent)'); + didAutoInstall = true; + await installBrowser(this.browserConfig); + continue; + } if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) { // User data directory is already in use, try again. await new Promise(resolve => setTimeout(resolve, 1000)); @@ -275,3 +277,24 @@ async function findFreePort(): Promise { server.on('error', reject); }); } + +/** + * Launches the given browser, auto-installing it if the executable is missing. + * Only retries once — if install succeeds and launch still fails, we surface + * the original error to the caller. + */ +export async function launchWithAutoInstall( + browserConfig: FullConfig['browser'], + launchOptions: playwright.LaunchOptions, + browserType: playwright.BrowserType, +): Promise { + try { + return await browserType.launch(launchOptions); + } catch (error) { + if (!isMissingBrowserError(error)) + throw error; + testDebug('browser missing, attempting auto-install (isolated)'); + await installBrowser(browserConfig); + return browserType.launch(launchOptions); + } +} diff --git a/src/browserInstaller.ts b/src/browserInstaller.ts new file mode 100644 index 0000000..e69abff --- /dev/null +++ b/src/browserInstaller.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fork } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { testDebug } from './log.js'; + +import type { FullConfig } from './config.js'; + +// Cache installs per-process so repeated launches don't re-run the installer. +const installedBrowsers = new Set(); +const inflightInstalls = new Map>(); + +function browserTarget(browserConfig: FullConfig['browser']): string { + return browserConfig.launchOptions?.channel + ?? browserConfig.browserName + ?? 'chrome'; +} + +/** + * Runs `playwright install ` in a forked child process. + * Safe to call concurrently — in-flight installs are deduplicated per target. + */ +export async function installBrowser(browserConfig: FullConfig['browser']): Promise { + const target = browserTarget(browserConfig); + + if (installedBrowsers.has(target)) + return; + + const existing = inflightInstalls.get(target); + if (existing) + return existing; + + const promise = runInstall(target).then(() => { + installedBrowsers.add(target); + }).finally(() => { + inflightInstalls.delete(target); + }); + + inflightInstalls.set(target, promise); + return promise; +} + +async function runInstall(target: string): Promise { + testDebug(`auto-installing browser: ${target}`); + const cliPath = resolvePlaywrightCli(); + + await runPlaywrightCli(cliPath, ['install', target]); + + // Best-effort system-deps install. Only runs when we're already root, + // otherwise skipped silently — users will see Playwright's own missing-lib + // error on the next launch, which tells them exactly what to apt install. + if (process.getuid && process.getuid() === 0) { + try { + await runPlaywrightCli(cliPath, ['install-deps', target]); + } catch (e) { + testDebug(`install-deps failed (non-fatal): ${e}`); + } + } +} + +function resolvePlaywrightCli(): string { + try { + const cliUrl = import.meta.resolve('playwright/package.json'); + return path.join(fileURLToPath(cliUrl), '..', 'cli.js'); + } catch (e) { + throw new Error( + 'Playwright package not found. Install it with: npm install playwright\n' + + `Original error: ${e instanceof Error ? e.message : String(e)}` + ); + } +} + +function runPlaywrightCli(cliPath: string, args: string[]): Promise { + const child = fork(cliPath, args, { 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(); + return; + } + reject(new Error(`playwright ${args.join(' ')} failed (exit ${code}):\n${output.join('')}`)); + }); + child.on('error', reject); + }); +} + +/** + * Returns true if the given error indicates the browser executable is missing + * and needs to be downloaded via `playwright install`. + */ +export function isMissingBrowserError(error: unknown): boolean { + if (!(error instanceof Error)) + return false; + const msg = error.message; + return msg.includes("Executable doesn't exist") + || msg.includes('please run the following command') + || msg.includes('npx playwright install'); +} diff --git a/src/context.ts b/src/context.ts index 7b0b0bf..a0f3dd5 100644 --- a/src/context.ts +++ b/src/context.ts @@ -23,12 +23,13 @@ import { Tab } from './tab.js'; import { EnvironmentIntrospector } from './environmentIntrospection.js'; import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js'; import { ArtifactManagerRegistry } from './artifactManager.js'; +import { launchWithAutoInstall } from './browserContextFactory.js'; +import { PlaywrightRipgrepEngine } from './filtering/engine.js'; import type { Tool, WebNotification, RTCConnectionData } from './tools/tool.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type { InjectionConfig } from './tools/codeInjection.js'; -import { PlaywrightRipgrepEngine } from './filtering/engine.js'; import type { DifferentialFilterParams } from './filtering/models.js'; // Virtual Accessibility Tree for React-style reconciliation @@ -466,7 +467,7 @@ export class Context { ]; } - const browser = await browserType.launch(launchOptions); + const browser = await launchWithAutoInstall(this.config.browser, launchOptions, browserType); // Use environment-specific video directory if available const videoConfig = envOptions.recordVideo ?