diff --git a/extension/src/background.ts b/extension/src/background.ts index 97c7306..a794f8f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -4,6 +4,7 @@ import { createStore } from 'zustand/vanilla' import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/protocol' +import { handleGhostBrowserCommand, type GhostBrowserCommandParams } from 'playwriter/src/ghost-browser' import { getActiveRecordings, handleStartRecording, @@ -278,51 +279,14 @@ class ConnectionManager { // This allows calling chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects // from the playwriter executor sandbox when running in Ghost Browser if (message.method === 'ghost-browser') { - const { namespace, method, args } = message.params as { - namespace: 'ghostPublicAPI' | 'ghostProxies' | 'projects' - method: string - args: unknown[] - } - try { - const api = (chrome as any)[namespace] - if (!api) { - sendMessage({ - id: message.id, - result: { success: false, error: `chrome.${namespace} not available (not running in Ghost Browser?)` } - }) - return - } - - const fn = api[method] - if (typeof fn !== 'function') { - // Check if it's a constant/property - if (method in api) { - sendMessage({ id: message.id, result: { success: true, result: api[method] } }) - return - } - sendMessage({ - id: message.id, - result: { success: false, error: `chrome.${namespace}.${method} is not a function or property` } - }) - return - } - - // Ghost Browser APIs use callback pattern, wrap in Promise - const result = await new Promise((resolve, reject) => { - fn.call(api, ...args, (result: unknown) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)) - } else { - resolve(result) - } - }) - }) - - sendMessage({ id: message.id, result: { success: true, result } }) - } catch (error: any) { - logger.error('Ghost Browser API error:', error) - sendMessage({ id: message.id, result: { success: false, error: error.message } }) + const result = await handleGhostBrowserCommand( + message.params as GhostBrowserCommandParams, + chrome + ) + if (!result.success) { + logger.error('Ghost Browser API error:', result.error) } + sendMessage({ id: message.id, result }) return } diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 07d5c76..3e55c22 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -28,6 +28,7 @@ import { type ScreenshotResult, type SnapshotFormat, } from './aria-snapshot.js' +import { createGhostBrowserChrome, type GhostBrowserCommandResult } from './ghost-browser.js' export type { SnapshotFormat } import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js' import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js' @@ -741,55 +742,17 @@ export class PlaywrightExecutor { } const self = this - // Ghost Browser API integration - creates proxy objects that mirror chrome.ghostPublicAPI, - // chrome.ghostProxies, and chrome.projects APIs. Only works when running in Ghost Browser. - // See extension/src/ghost-browser-api.d.ts for full API documentation. - const createGhostBrowserProxy = ( - namespace: 'ghostPublicAPI' | 'ghostProxies' | 'projects', - constants: Record = {} - ) => { - const sendGhostBrowserCommand = async (method: string, args: unknown[]) => { - const cdp = await getCDPSession({ page }) - const result = await cdp.send('ghost-browser' as any, { namespace, method, args }) - const typed = result as { success: boolean; result?: unknown; error?: string } - if (!typed.success) { - throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`) - } - return typed.result + // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs + // See extension/src/ghost-browser-api.d.ts for full API documentation + const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => { + const cdp = await getCDPSession({ page }) + const result = await cdp.send('ghost-browser' as any, { namespace, method, args }) + const typed = result as GhostBrowserCommandResult + if (!typed.success) { + throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`) } - - return new Proxy(constants, { - get(target, prop: string) { - // Return constants directly (no await needed) - if (prop in target) { - return target[prop] - } - // Return function that sends ghost-browser command - return (...args: unknown[]) => sendGhostBrowserCommand(prop, args) - } - }) - } - - // Chrome object with Ghost Browser API namespaces - // These mirror the exact shape of chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects - const chromeGhostBrowser = { - ghostPublicAPI: createGhostBrowserProxy('ghostPublicAPI', { - NEW_TEMPORARY_IDENTITY: 'OpenInNewSession', - DEFAULT_IDENTITY: '', - MAX_TEMPORARY_IDENTITIES: 25, - }), - ghostProxies: createGhostBrowserProxy('ghostProxies', { - DIRECT_PROXY: '8f513494-8cf5-41c7-b318-936392222104', - SYSTEM_PROXY: '2485b989-7ffb-4442-a45a-d7f9a10c6171', - }), - projects: createGhostBrowserProxy('projects', { - PROJECT_ID_HOME: 'f0673216-13b9-48be-aa41-90763e229e78', - PROJECT_ID_UNSAVED: 'fe061488-8a8e-40f0-9e5e-93a1a5e2c273', - SESSIONS_MAX: 25, - NEW_SESSION: 'OpenInNewSession', - GLOBAL_SESSION: '', - }), - } + return typed.result + }) let vmContextObj: any = { page, diff --git a/playwriter/src/ghost-browser.ts b/playwriter/src/ghost-browser.ts new file mode 100644 index 0000000..2ef5192 --- /dev/null +++ b/playwriter/src/ghost-browser.ts @@ -0,0 +1,151 @@ +/** + * Ghost Browser API integration for Playwriter + * + * Shared code for both executor (Node.js) and extension (Chrome) environments. + * See extension/src/ghost-browser-api.d.ts for full API documentation. + */ + +// ============================================================================= +// Constants - same values as in Ghost Browser +// ============================================================================= + +export const GHOST_PUBLIC_API_CONSTANTS = { + NEW_TEMPORARY_IDENTITY: 'OpenInNewSession', + DEFAULT_IDENTITY: '', + MAX_TEMPORARY_IDENTITIES: 25, +} as const + +export const GHOST_PROXIES_CONSTANTS = { + DIRECT_PROXY: '8f513494-8cf5-41c7-b318-936392222104', + SYSTEM_PROXY: '2485b989-7ffb-4442-a45a-d7f9a10c6171', +} as const + +export const GHOST_PROJECTS_CONSTANTS = { + PROJECT_ID_HOME: 'f0673216-13b9-48be-aa41-90763e229e78', + PROJECT_ID_UNSAVED: 'fe061488-8a8e-40f0-9e5e-93a1a5e2c273', + SESSIONS_MAX: 25, + NEW_SESSION: 'OpenInNewSession', + GLOBAL_SESSION: '', +} as const + +// ============================================================================= +// Types +// ============================================================================= + +export type GhostBrowserNamespace = 'ghostPublicAPI' | 'ghostProxies' | 'projects' + +export type GhostBrowserCommandParams = { + namespace: GhostBrowserNamespace + method: string + args: unknown[] +} + +export type GhostBrowserCommandResult = + | { success: true; result: unknown } + | { success: false; error: string } + +/** + * Function signature for sending ghost-browser commands. + * In executor: sends via CDP session + * In extension: calls chrome.* APIs directly + */ +export type SendGhostBrowserCommand = ( + namespace: GhostBrowserNamespace, + method: string, + args: unknown[] +) => Promise + +// ============================================================================= +// Executor: Chrome object factory +// ============================================================================= + +/** + * Creates a proxy object for a Ghost Browser API namespace. + * Constants are returned directly, methods are forwarded via sendCommand. + */ +function createGhostBrowserProxy( + namespace: GhostBrowserNamespace, + constants: Record, + sendCommand: SendGhostBrowserCommand +) { + return new Proxy(constants, { + get(target, prop: string) { + // Return constants directly (no await needed) + if (prop in target) { + return target[prop] + } + // Return function that sends ghost-browser command + return (...args: unknown[]) => sendCommand(namespace, prop, args) + }, + }) +} + +/** + * Creates the chrome object with Ghost Browser API namespaces for the executor sandbox. + * Mirrors the exact shape of chrome.ghostPublicAPI, chrome.ghostProxies, chrome.projects. + * + * @param sendCommand - Function to send commands to the extension + */ +export function createGhostBrowserChrome(sendCommand: SendGhostBrowserCommand) { + return { + ghostPublicAPI: createGhostBrowserProxy('ghostPublicAPI', GHOST_PUBLIC_API_CONSTANTS, sendCommand), + ghostProxies: createGhostBrowserProxy('ghostProxies', GHOST_PROXIES_CONSTANTS, sendCommand), + projects: createGhostBrowserProxy('projects', GHOST_PROJECTS_CONSTANTS, sendCommand), + } +} + +// ============================================================================= +// Extension: Command handler +// ============================================================================= + +/** + * Handles ghost-browser commands in the extension. + * Calls the appropriate chrome.* API and returns the result. + * + * @param params - Command parameters (namespace, method, args) + * @param chromeApi - The chrome object (passed to avoid global dependency) + * @returns Result object with success/error status + */ +export async function handleGhostBrowserCommand( + params: GhostBrowserCommandParams, + chromeApi: typeof chrome +): Promise { + const { namespace, method, args } = params + + try { + const api = (chromeApi as any)[namespace] + if (!api) { + return { + success: false, + error: `chrome.${namespace} not available (not running in Ghost Browser?)`, + } + } + + const fn = api[method] + if (typeof fn !== 'function') { + // Check if it's a constant/property + if (method in api) { + return { success: true, result: api[method] } + } + return { + success: false, + error: `chrome.${namespace}.${method} is not a function or property`, + } + } + + // Ghost Browser APIs use callback pattern, wrap in Promise + const result = await new Promise((resolve, reject) => { + fn.call(api, ...args, (result: unknown) => { + if (chromeApi.runtime.lastError) { + reject(new Error(chromeApi.runtime.lastError.message)) + } else { + resolve(result) + } + }) + }) + + return { success: true, result } + } catch (error: any) { + return { success: false, error: error.message } + } +}