/** * Playwright Electron Automation Script * * This module provides functions for launching and interacting with the Electron desktop app * using Playwright. It enables other agents to automate the app with existing environment * credentials and perform automated testing or interaction workflows. * * Usage: * - Install Playwright browsers: `npm run playwright:install` * - Run example automation: `npm run playwright:example` * * Key Functions: * - launchElectronWithCredentials(): Basic app launch * - launchElectronWithWindow(): Launch with window access * - waitForAppReady(): Wait for full app initialization * - getMainWindow(): Get main window with retry logic * - closeElectronApp(): Graceful app shutdown */ import { _electron as electron } from 'playwright'; import type { ElectronApplication, Page } from 'playwright'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Log entry structure for main process logs */ export interface LogEntry { timestamp: Date; level: string; message: string; source: string; } /** * Launches the Electron application with existing environment credentials * for automated testing and interaction via Playwright MCP */ export async function launchElectronWithCredentials(): Promise { const desktopPath = path.join(__dirname, '..'); const requiredEnvVars = { HEDERA_OPERATOR_ID: process.env.HEDERA_OPERATOR_ID, HEDERA_OPERATOR_KEY: process.env.HEDERA_OPERATOR_KEY, HEDERA_NETWORK: process.env.HEDERA_NETWORK || 'testnet', OPENAI_API_KEY: process.env.OPENAI_API_KEY, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }; const availableCredentials = Object.entries(requiredEnvVars) .filter(([key, value]) => value) .map(([key]) => key); if (availableCredentials.length === 0) { console.warn('⚠️ No credentials found in environment variables'); console.warn(' Please ensure you have a .env file or environment variables set'); } else { console.log('✓ Found credentials:', availableCredentials.join(', ')); } try { console.log('Attempting to connect to running Electron Forge dev server...'); const devMainPath = path.join(desktopPath, '.vite', 'build', 'main.js'); const app = await electron.launch({ args: [devMainPath], cwd: desktopPath, env: { ...process.env, ...requiredEnvVars, NODE_ENV: 'development', ELECTRON_ENABLE_LOGGING: '1', }, timeout: 10000, // Shorter timeout for dev server }); console.log('✓ Connected to development server successfully'); return app; } catch (error) { console.log('⚠ Development build failed, trying packaged app...'); const platformPaths = { darwin: path.join(desktopPath, 'out', 'HOL Desktop-darwin-arm64', 'HOL Desktop.app', 'Contents', 'MacOS', 'HOL Desktop'), linux: path.join(desktopPath, 'out', 'HOL Desktop-linux-x64', 'HOL Desktop'), win32: path.join(desktopPath, 'out', 'HOL Desktop-win32-x64', 'HOL Desktop.exe'), }; const executablePath = platformPaths[process.platform as keyof typeof platformPaths]; if (!executablePath) { throw new Error(`Unsupported platform: ${process.platform}`); } try { const app = await electron.launch({ executablePath, env: { ...process.env, ...requiredEnvVars, // Explicitly pass all required env vars for packaged app too NODE_ENV: 'production', }, timeout: 60000, }); console.log('✓ Packaged app launched successfully'); return app; } catch (pkgError) { console.error('✗ Failed to launch app:', pkgError); throw new Error('Could not launch Electron app with any strategy'); } } } /** * Launches Electron app and returns both the app instance and first window * for more convenient access to the main window */ export async function launchElectronWithWindow(): Promise<{ app: ElectronApplication; window: Page; }> { const app = await launchElectronWithCredentials(); try { console.log('Waiting for first window...'); let window: Page; let attempts = 0; const maxAttempts = 10; while (attempts < maxAttempts) { try { window = await app.firstWindow(); console.log('✓ First window obtained'); break; } catch (error) { attempts++; console.log(`Attempt ${attempts}/${maxAttempts} to get window failed: ${error}`); if (attempts >= maxAttempts) { throw new Error(`Failed to get first window after ${maxAttempts} attempts`); } await new Promise(resolve => setTimeout(resolve, 2000)); } } console.log('Waiting for DOM content to load...'); await window!.waitForLoadState('domcontentloaded', { timeout: 30000 }); console.log('✓ DOM content loaded'); await window!.waitForTimeout(2000); console.log('✓ Window ready'); return { app, window: window! }; } catch (error) { console.error('Failed to prepare window:', error); await app.close(); throw error; } } /** * Gracefully closes the Electron application */ export async function closeElectronApp(app: ElectronApplication): Promise { await app.close(); } /** * Helper function to get the app's main window with retry logic */ export async function getMainWindow( app: ElectronApplication, maxRetries: number = 3 ): Promise { let attempts = 0; while (attempts < maxRetries) { try { const window = await app.firstWindow(); await window.waitForLoadState('domcontentloaded'); return window; } catch (error) { attempts++; if (attempts >= maxRetries) { throw new Error(`Failed to get main window after ${maxRetries} attempts: ${error}`); } await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error('Should not reach this point'); } /** * Validates that Hedera credentials are available in the Electron app * Returns true if credentials are properly configured */ export async function validateCredentials( app: ElectronApplication ): Promise { try { const hasCredentials = await app.evaluate(() => { const hasOperatorId = Boolean(process.env.HEDERA_OPERATOR_ID); const hasOperatorKey = Boolean(process.env.HEDERA_OPERATOR_KEY); console.log('Credential validation:', { hasOperatorId, hasOperatorKey, operatorId: hasOperatorId ? process.env.HEDERA_OPERATOR_ID : 'Not set', network: process.env.HEDERA_NETWORK || 'testnet' }); return hasOperatorId && hasOperatorKey; }); if (!hasCredentials) { console.error('❌ Credentials not found in app process'); console.error(' Ensure environment variables are set before launching'); return false; } console.log('✓ Credentials validated successfully'); return true; } catch (error) { console.error('Failed to validate credentials:', error); return false; } } /** * Waits for the app to be ready and returns true when initialization is complete */ export async function waitForAppReady( app: ElectronApplication, timeoutMs: number = 10000 ): Promise { const window = await getMainWindow(app); try { await window.waitForSelector('[data-testid="app-container"], .app, #root', { timeout: timeoutMs, }); await window.waitForTimeout(500); return true; } catch (error) { console.error('App readiness check failed:', error); return false; } } /** * Waits for the chat screen to be fully ready and usable * Checks for essential UI elements including input field, send button, and sidebar */ export async function waitForChatReady( page: Page, timeout: number = 30000 ): Promise { try { await page.waitForSelector('div[class*="flex flex-col"], div[class*="h-full"]', { timeout: timeout / 4 }); await page.waitForSelector('textarea[placeholder*="Message"], textarea[placeholder*="Type"]', { timeout: timeout / 4, state: 'visible' }); await page.waitForSelector('button:has-text(""), button[class*="send"], button:has(svg)', { timeout: timeout / 4 }); await page.waitForSelector('button[class*="session"], div[class*="sidebar"], nav', { timeout: timeout / 4 }); const inputField = await page.locator('textarea[placeholder*="Message"], textarea[placeholder*="Type"]').first(); await inputField.waitFor({ state: 'visible', timeout: 2000 }); const isDisabled = await inputField.isDisabled(); if (isDisabled) { console.warn('Chat input field is disabled - chat may not be fully ready'); return false; } return true; } catch (error) { console.error('Chat readiness check failed:', error); return false; } } /** * Captures and aggregates logs from the Electron main process * Provides methods to collect console output with timestamps for debugging */ export function captureMainProcessLogs(app: ElectronApplication): { getLogs: () => LogEntry[]; clear: () => void; } { const logs: LogEntry[] = []; app.on('console', (msg) => { logs.push({ timestamp: new Date(), level: msg.type() || 'info', message: msg.text(), source: 'main' }); }); return { getLogs: () => [...logs], clear: () => logs.length = 0 }; } /** * Captures and aggregates logs from the Electron renderer process * Uses page console events to collect browser console output with timestamps */ export function captureRendererLogs(page: Page): { getLogs: () => LogEntry[]; clear: () => void; } { const logs: LogEntry[] = []; page.on('console', (msg) => { logs.push({ timestamp: new Date(), level: msg.type(), message: msg.text(), source: 'renderer' }); }); return { getLogs: () => [...logs], clear: () => logs.length = 0 }; } /** * Captures and aggregates logs from all sources (main process and renderer) * Provides a unified interface for accessing, filtering, and clearing logs */ export function captureAllLogs( app: ElectronApplication, page: Page ): { getAllLogs: () => LogEntry[]; getBySource: (source: string) => LogEntry[]; clear: () => void; } { const mainLogs = captureMainProcessLogs(app); const rendererLogs = captureRendererLogs(page); return { getAllLogs: () => { const allLogs = [...mainLogs.getLogs(), ...rendererLogs.getLogs()]; return allLogs.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); }, getBySource: (source: string) => { const allLogs = [...mainLogs.getLogs(), ...rendererLogs.getLogs()]; return allLogs.filter(log => log.source === source) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); }, clear: () => { mainLogs.clear(); rendererLogs.clear(); } }; }