Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 51 additions & 36 deletions apps/cli/ai/browser-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -81,39 +81,53 @@ 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.
*/
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( () => {} );
Expand Down Expand Up @@ -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;
}
Expand Down
24 changes: 15 additions & 9 deletions apps/cli/ai/inspector/inspector-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ] > >;
Expand Down Expand Up @@ -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
Expand Down
Loading