From 72e27bbb29033b88b21ff3fa04e0a3da06fabd34 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 16 Jun 2026 11:00:39 +0200 Subject: [PATCH 1/2] Auto-install Playwright Chromium when opening the annotation browser Co-Authored-By: Claude Opus 4.8 --- apps/cli/ai/browser-utils.ts | 87 +++++++++++++---------- apps/cli/ai/inspector/inspector-inject.ts | 24 ++++--- apps/cli/ai/tests/browser-utils.test.ts | 25 +++++++ 3 files changed, 91 insertions(+), 45 deletions(-) diff --git a/apps/cli/ai/browser-utils.ts b/apps/cli/ai/browser-utils.ts index 71b63357f1..26eaaf5228 100644 --- a/apps/cli/ai/browser-utils.ts +++ b/apps/cli/ai/browser-utils.ts @@ -24,21 +24,21 @@ let browserPromise: Promise< Browser > | null = null; const execFileAsync = promisify( execFile ); export function buildChromiumLaunchAttempts( - chromium: Pick< Chromium, 'executablePath' > + chromium: Pick< Chromium, 'executablePath' >, + overrides: ChromiumLaunchOptions = {} ): ChromiumLaunchOptions[] { const attempts: ChromiumLaunchOptions[] = []; const executablePath = chromium.executablePath(); + const base: ChromiumLaunchOptions = { + args: DEFAULT_BROWSER_ARGS, + ...overrides, + }; if ( executablePath && existsSync( executablePath ) ) { - attempts.push( { - args: DEFAULT_BROWSER_ARGS, - executablePath, - } ); + attempts.push( { ...base, executablePath } ); } - attempts.push( { - args: DEFAULT_BROWSER_ARGS, - } ); + attempts.push( { ...base } ); return attempts; } @@ -81,6 +81,45 @@ export async function ensurePlaywrightChromiumInstalled( return null; } +/** + * Launch a Chromium browser, auto-installing Playwright's managed Chromium if + * it is missing. Accepts launch `overrides` (e.g. `headless: false` and custom + * `args`) so callers that need their own window configuration still get the + * executable-path fallback and on-demand install behaviour. Throws a + * diagnostic error if no browser can be launched. + */ +export async function launchChromiumWithInstall( + overrides: ChromiumLaunchOptions = {}, + toolContext = 'Studio MCP screenshot/validation tools' +): Promise< Browser > { + const { chromium } = await import( 'playwright' ); + const launchErrors: string[] = []; + let browser = await tryLaunchChromium( chromium, launchErrors, overrides ); + let installError: string | null = null; + + if ( ! browser ) { + installError = await ensurePlaywrightChromiumInstalled( chromium ); + if ( ! installError ) { + browser = await tryLaunchChromium( chromium, launchErrors, overrides ); + } + } + + if ( ! browser ) { + const repairGuidance = + installError ?? + 'If Playwright Chromium is missing, run `studio mcp` again with network access so Studio can install it automatically.'; + + throw new Error( + `Unable to launch a browser for ${ toolContext }. ` + + `Tried ${ launchErrors.map( ( error ) => error.split( ': ', 1 )[ 0 ] ).join( ', ' ) }. ` + + `${ repairGuidance } ` + + `Launch errors: ${ launchErrors.join( ' | ' ) }` + ); + } + + return browser; +} + /** * Returns (and lazily launches) a shared Chromium browser instance. * The browser is cleaned up automatically on process exit. @@ -88,32 +127,7 @@ export async function ensurePlaywrightChromiumInstalled( export async function getSharedBrowser(): Promise< Browser > { if ( ! browserPromise ) { browserPromise = ( async () => { - const { chromium } = await import( 'playwright' ); - const launchErrors: string[] = []; - let browser = await tryLaunchChromium( chromium, launchErrors ); - let installError: string | null = null; - - if ( ! browser ) { - installError = await ensurePlaywrightChromiumInstalled( chromium ); - if ( ! installError ) { - browser = await tryLaunchChromium( chromium, launchErrors ); - } - } - - if ( ! browser ) { - const repairGuidance = - installError ?? - 'If Playwright Chromium is missing, run `studio mcp` again with network access so Studio can install it automatically.'; - - throw new Error( - 'Unable to launch a browser for Studio MCP screenshot/validation tools. ' + - `Tried ${ launchErrors - .map( ( error ) => error.split( ': ', 1 )[ 0 ] ) - .join( ', ' ) }. ` + - `${ repairGuidance } ` + - `Launch errors: ${ launchErrors.join( ' | ' ) }` - ); - } + const browser = await launchChromiumWithInstall(); const cleanup = () => { browser.close().catch( () => {} ); @@ -143,9 +157,10 @@ export async function closeSharedBrowser(): Promise< void > { async function tryLaunchChromium( chromium: Pick< Chromium, 'launch' | 'executablePath' >, - launchErrors: string[] + launchErrors: string[], + overrides: ChromiumLaunchOptions = {} ): Promise< Browser | undefined > { - for ( const attempt of buildChromiumLaunchAttempts( chromium ) ) { + for ( const attempt of buildChromiumLaunchAttempts( chromium, overrides ) ) { if ( ! attempt ) { continue; } diff --git a/apps/cli/ai/inspector/inspector-inject.ts b/apps/cli/ai/inspector/inspector-inject.ts index 6d034699c6..2b724c482b 100644 --- a/apps/cli/ai/inspector/inspector-inject.ts +++ b/apps/cli/ai/inspector/inspector-inject.ts @@ -8,6 +8,7 @@ * "Done" to send everything back to the CLI via `window.__studioAnnotateDone`. */ +import { launchChromiumWithInstall } from 'cli/ai/browser-utils'; import { INSPECTOR_PAGE_SCRIPT } from 'cli/ai/inspector/page-script'; type Browser = Awaited< ReturnType< ( typeof import('playwright') )[ 'chromium' ][ 'launch' ] > >; @@ -137,15 +138,20 @@ export async function openAnnotationBrowser( siteUrl: string ): Promise< string } } - const { chromium } = await import( 'playwright' ); - inspectorBrowser = await chromium.launch( { - headless: false, - // 1280x800 fits comfortably on a 13" MacBook (1440x900 native) once - // macOS chrome and the Chrome url bar are accounted for. With a larger - // window the bottom of the page can be clipped off-screen, hiding the - // `position: fixed; bottom: 1.25rem` toolbar. - args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], - } ); + // Reuse the shared launcher so a missing/outdated Playwright Chromium is + // auto-installed on demand, exactly like the screenshot and validation + // tools — instead of failing with a raw "please run install" error. + inspectorBrowser = await launchChromiumWithInstall( + { + headless: false, + // 1280x800 fits comfortably on a 13" MacBook (1440x900 native) once + // macOS chrome and the Chrome url bar are accounted for. With a larger + // window the bottom of the page can be clipped off-screen, hiding the + // `position: fixed; bottom: 1.25rem` toolbar. + args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], + }, + 'the Studio annotation browser' + ); // `viewport: null` makes the page area follow the actual window size, so // `position: fixed` lands inside the visible region regardless of the diff --git a/apps/cli/ai/tests/browser-utils.test.ts b/apps/cli/ai/tests/browser-utils.test.ts index bc34b3e48c..0fc1e940f0 100644 --- a/apps/cli/ai/tests/browser-utils.test.ts +++ b/apps/cli/ai/tests/browser-utils.test.ts @@ -41,6 +41,31 @@ describe( 'browser-utils', () => { expect( options?.args ).toEqual( [ '--ignore-certificate-errors' ] ); } ); + it( 'merges launch overrides into every attempt so headed callers keep the install fallback', () => { + const overrides = { + headless: false, + args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], + }; + + const attempts = buildChromiumLaunchAttempts( + { executablePath: () => process.execPath }, + overrides + ); + + // Both the resolved-executable attempt and the default fallback must + // carry the caller's overrides. + expect( attempts ).toHaveLength( 2 ); + expect( attempts[ 0 ] ).toEqual( { + headless: false, + args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], + executablePath: process.execPath, + } ); + expect( attempts.at( -1 ) ).toEqual( { + headless: false, + args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], + } ); + } ); + it( 'tries to install Playwright Chromium when the managed browser is missing', async () => { const installBrowser = vi.fn().mockResolvedValue( undefined ); let installed = false; From 38c3d010ef55ab0d2993c4e596b54d9ffcdbe363 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 16 Jun 2026 11:01:44 +0200 Subject: [PATCH 2/2] Drop redundant override-merging test --- apps/cli/ai/tests/browser-utils.test.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/apps/cli/ai/tests/browser-utils.test.ts b/apps/cli/ai/tests/browser-utils.test.ts index 0fc1e940f0..bc34b3e48c 100644 --- a/apps/cli/ai/tests/browser-utils.test.ts +++ b/apps/cli/ai/tests/browser-utils.test.ts @@ -41,31 +41,6 @@ describe( 'browser-utils', () => { expect( options?.args ).toEqual( [ '--ignore-certificate-errors' ] ); } ); - it( 'merges launch overrides into every attempt so headed callers keep the install fallback', () => { - const overrides = { - headless: false, - args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], - }; - - const attempts = buildChromiumLaunchAttempts( - { executablePath: () => process.execPath }, - overrides - ); - - // Both the resolved-executable attempt and the default fallback must - // carry the caller's overrides. - expect( attempts ).toHaveLength( 2 ); - expect( attempts[ 0 ] ).toEqual( { - headless: false, - args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], - executablePath: process.execPath, - } ); - expect( attempts.at( -1 ) ).toEqual( { - headless: false, - args: [ '--ignore-certificate-errors', '--window-size=1280,800' ], - } ); - } ); - it( 'tries to install Playwright Chromium when the managed browser is missing', async () => { const installBrowser = vi.fn().mockResolvedValue( undefined ); let installed = false;