/** * Electron Application Fixture for E2E Testing * * This fixture handles launching and managing the Electron application * for Playwright E2E tests. It provides utilities for interacting with * the app's main window and IPC communication. */ import { test as base, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; import path from 'path'; import fs from 'fs'; import os from 'os'; // Interface for our extended test fixtures interface ElectronTestFixtures { electronApp: ElectronApplication; window: Page; appPath: string; testDataDir: string; } /** * Get the path to the Electron application * In development, we use the built main process * In CI/production, we could use the packaged app */ function getElectronPath(): string { // For now, we run in development mode using the built main process // The app must be built first: npm run build:main && npm run build:renderer return require('electron') as unknown as string; } /** * Get the path to the main entry point */ function getMainPath(): string { return path.join(__dirname, '../../dist/main/index.js'); } /** * Create a unique test data directory for isolation */ function createTestDataDir(): string { const testDir = path.join(os.tmpdir(), `maestro-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(testDir, { recursive: true }); return testDir; } /** * Extended test with Electron fixtures * * Usage: * ```typescript * import { test, expect } from './fixtures/electron-app'; * * test('should launch the app', async ({ electronApp, window }) => { * await expect(window.locator('h1')).toBeVisible(); * }); * ``` */ export const test = base.extend({ // Test data directory for isolation testDataDir: async ({}, use) => { const dir = createTestDataDir(); await use(dir); // Cleanup after test try { fs.rmSync(dir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }, // Path to the main entry point appPath: async ({}, use) => { const mainPath = getMainPath(); // Check if the app is built if (!fs.existsSync(mainPath)) { throw new Error( `Electron main process not built. Run 'npm run build:main && npm run build:renderer' first.\n` + `Expected path: ${mainPath}` ); } await use(mainPath); }, // Launch Electron application electronApp: async ({ appPath, testDataDir }, use) => { const electronPath = getElectronPath(); // Launch the Electron app const app = await electron.launch({ args: [appPath], env: { ...process.env, // Use isolated data directory for tests MAESTRO_DATA_DIR: testDataDir, // Disable hardware acceleration for CI ELECTRON_DISABLE_GPU: '1', // Set NODE_ENV to test NODE_ENV: 'test', // Ensure we're in a testing context MAESTRO_E2E_TEST: 'true', }, // Increase timeout for slow CI environments timeout: 30000, }); await use(app); // Close the application after test await app.close(); }, // Get the main window window: async ({ electronApp }, use) => { // Wait for the first window to be available const window = await electronApp.firstWindow(); // Wait for the app to be ready (DOM loaded) await window.waitForLoadState('domcontentloaded'); // Give the app a moment to initialize React await window.waitForTimeout(500); await use(window); }, }); export { expect } from '@playwright/test'; /** * Helper utilities for E2E tests */ export const helpers = { /** * Wait for the wizard to be visible */ async waitForWizard(window: Page): Promise { // The wizard modal should have a specific structure // Looking for the wizard container or title await window.waitForSelector('text=Create a Maestro Agent', { timeout: 10000 }); }, /** * Open the wizard via keyboard shortcut */ async openWizardViaShortcut(window: Page): Promise { // Cmd+Shift+N opens the wizard await window.keyboard.press('Meta+Shift+N'); await helpers.waitForWizard(window); }, /** * Select an agent in the wizard */ async selectAgent(window: Page, agentName: string): Promise { // Find and click the agent tile const agentTile = window.locator(`text=${agentName}`).first(); await agentTile.click(); }, /** * Enter a project name in the wizard */ async enterProjectName(window: Page, name: string): Promise { // Find the Name input field const nameInput = window.locator('input[placeholder*="Project"]').or( window.locator('input[placeholder*="Name"]') ); await nameInput.fill(name); }, /** * Click the Next button in the wizard */ async clickNext(window: Page): Promise { const nextButton = window.locator('button:has-text("Next")').or( window.locator('button:has-text("Continue")') ); await nextButton.click(); }, /** * Click the Back button in the wizard */ async clickBack(window: Page): Promise { const backButton = window.locator('button:has-text("Back")'); await backButton.click(); }, /** * Select a directory in the wizard * Note: This requires mocking the native dialog or using a pre-configured directory */ async selectDirectory(window: Page, dirPath: string): Promise { // The directory selection involves a native dialog // For E2E tests, we might need to: // 1. Mock the dialog result via IPC // 2. Use a pre-selected directory // 3. Set up the directory state before the test // For now, we'll look for the directory input and interact with it // This may need to be adjusted based on actual implementation throw new Error('Directory selection requires dialog mocking - implement based on app specifics'); }, /** * Wait for the wizard to close */ async waitForWizardClose(window: Page): Promise { // Wait for the wizard title to disappear await window.waitForSelector('text=Create a Maestro Agent', { state: 'hidden', timeout: 10000, }); }, /** * Check if the app is showing the main UI */ async waitForMainUI(window: Page): Promise { // Wait for key elements of the main UI to be visible // Adjust these selectors based on actual UI structure await window.waitForSelector('[data-tour]', { timeout: 10000 }).catch(() => { // data-tour attributes might not exist, try another approach }); }, /** * Create a temporary test directory structure */ createTestDirectory(basePath: string, structure: Record): void { for (const [relativePath, content] of Object.entries(structure)) { const fullPath = path.join(basePath, relativePath); const dir = path.dirname(fullPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } if (content !== null) { fs.writeFileSync(fullPath, content, 'utf-8'); } } }, /** * Clean up test directory */ cleanupTestDirectory(dirPath: string): void { try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }, // ============================================ // Auto Run Helpers // ============================================ /** * Navigate to the Auto Run tab in the right panel */ async navigateToAutoRunTab(window: Page): Promise { const autoRunTab = window.locator('text=Auto Run'); if (await autoRunTab.count() > 0) { await autoRunTab.first().click(); return true; } return false; }, /** * Switch Auto Run to edit mode */ async switchToEditMode(window: Page): Promise { const editButton = window.locator('button').filter({ hasText: 'Edit' }); if (await editButton.count() > 0 && await editButton.isVisible()) { await editButton.first().click(); return true; } return false; }, /** * Switch Auto Run to preview mode */ async switchToPreviewMode(window: Page): Promise { const previewButton = window.locator('button').filter({ hasText: 'Preview' }); if (await previewButton.count() > 0 && await previewButton.isVisible()) { await previewButton.first().click(); return true; } return false; }, /** * Get the Auto Run textarea element */ getAutoRunTextarea(window: Page) { return window.locator('textarea'); }, /** * Type content into the Auto Run editor */ async typeInAutoRunEditor(window: Page, content: string): Promise { const textarea = window.locator('textarea'); if (await textarea.count() > 0) { await textarea.fill(content); return true; } return false; }, /** * Get current content from Auto Run editor */ async getAutoRunContent(window: Page): Promise { const textarea = window.locator('textarea'); if (await textarea.count() > 0) { return await textarea.inputValue(); } return null; }, /** * Open the Auto Run expanded modal */ async openExpandedModal(window: Page): Promise { const expandButton = window.locator('button[title*="Expand"]').or( window.locator('button[title*="full screen"]') ); if (await expandButton.count() > 0) { await expandButton.first().click(); return true; } return false; }, /** * Check if Auto Run is in edit mode */ async isInEditMode(window: Page): Promise { const textarea = window.locator('textarea'); return await textarea.count() > 0 && await textarea.isVisible(); }, /** * Open search in Auto Run */ async openAutoRunSearch(window: Page): Promise { await window.keyboard.press('Meta+F'); }, /** * Create an Auto Run test folder with sample documents */ createAutoRunTestFolder(basePath: string): string { const autoRunFolder = path.join(basePath, 'Auto Run Docs'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create sample documents fs.writeFileSync( path.join(autoRunFolder, 'Phase 1.md'), `# Phase 1: Setup ## Tasks - [ ] Task 1: Initialize project - [ ] Task 2: Configure environment - [x] Task 3: Review documentation ## Notes Sample content for testing Auto Run editing. ` ); fs.writeFileSync( path.join(autoRunFolder, 'Phase 2.md'), `# Phase 2: Implementation ## Tasks - [ ] Build feature A - [ ] Build feature B - [ ] Write tests ## Details More content for the second phase. ` ); return autoRunFolder; }, // ============================================ // Batch Processing Helpers // ============================================ /** * Get the Run button for batch processing */ getRunButton(window: Page) { return window.locator('button').filter({ hasText: /^run$/i }); }, /** * Get the Stop button for batch processing */ getStopButton(window: Page) { return window.locator('button').filter({ hasText: /stop/i }); }, /** * Click the Run button to open batch runner modal */ async clickRunButton(window: Page): Promise { const runButton = window.locator('button').filter({ hasText: /^run$/i }); if (await runButton.count() > 0 && await runButton.first().isEnabled()) { await runButton.first().click(); return true; } return false; }, /** * Click the Stop button to halt batch processing */ async clickStopButton(window: Page): Promise { const stopButton = window.locator('button').filter({ hasText: /stop/i }); if (await stopButton.count() > 0 && await stopButton.first().isEnabled()) { await stopButton.first().click(); return true; } return false; }, /** * Wait for batch runner modal to be visible */ async waitForBatchRunnerModal(window: Page): Promise { await window.waitForSelector('text=Auto Run Configuration', { timeout: 5000 }); }, /** * Click the Go button in batch runner modal to start processing */ async clickGoButton(window: Page): Promise { const goButton = window.locator('button').filter({ hasText: 'Go' }); if (await goButton.count() > 0 && await goButton.first().isEnabled()) { await goButton.first().click(); return true; } return false; }, /** * Check if batch run is currently active */ async isBatchRunActive(window: Page): Promise { // If Stop button is visible, batch run is active const stopButton = window.locator('button').filter({ hasText: /stop/i }); return (await stopButton.count() > 0) && await stopButton.first().isVisible(); }, /** * Check if textarea is in locked (readonly) state */ async isTextareaLocked(window: Page): Promise { const textarea = window.locator('textarea'); if (await textarea.count() > 0) { const readonly = await textarea.first().getAttribute('readonly'); return readonly !== null; } return false; }, /** * Get task count text from Auto Run panel */ async getTaskCountText(window: Page): Promise { const taskCount = window.locator('text=/\\d+ of \\d+ task/i'); if (await taskCount.count() > 0) { return await taskCount.first().textContent(); } return null; }, /** * Create an Auto Run test folder with batch processing test documents */ createBatchTestFolder(basePath: string): string { const autoRunFolder = path.join(basePath, 'Auto Run Docs'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create documents with varying task counts fs.writeFileSync( path.join(autoRunFolder, 'Phase 1.md'), `# Phase 1: Setup ## Tasks - [ ] Task 1: Initialize project structure - [ ] Task 2: Set up configuration files - [ ] Task 3: Create initial documentation ## Notes Test document for batch processing. ` ); fs.writeFileSync( path.join(autoRunFolder, 'Phase 2.md'), `# Phase 2: Implementation ## Tasks - [ ] Task 4: Build core functionality - [ ] Task 5: Add unit tests - [ ] Task 6: Implement error handling ## Details Second phase document. ` ); fs.writeFileSync( path.join(autoRunFolder, 'Completed.md'), `# Completed Tasks ## Tasks - [x] Done task 1 - [x] Done task 2 ## Summary All tasks complete in this document. ` ); return autoRunFolder; }, // ============================================ // Session Switching Helpers // ============================================ /** * Get all session items in the session list */ getSessionItems(window: Page) { return window.locator('[data-testid="session-item"]'); }, /** * Get the session list container */ getSessionList(window: Page) { return window.locator('[data-testid="session-list"]').or( window.locator('aside').first() ); }, /** * Click on a session by index in the session list */ async clickSessionByIndex(window: Page, index: number): Promise { const sessionItems = window.locator('[data-testid="session-item"]'); const count = await sessionItems.count(); if (index < count) { await sessionItems.nth(index).click(); return true; } return false; }, /** * Click on a session by name in the session list */ async clickSessionByName(window: Page, name: string): Promise { const sessionItem = window.locator(`[data-testid="session-item"]:has-text("${name}")`); if (await sessionItem.count() > 0) { await sessionItem.first().click(); return true; } // Try finding by text directly const sessionByText = window.locator(`text="${name}"`); if (await sessionByText.count() > 0) { await sessionByText.first().click(); return true; } return false; }, /** * Get the currently active session (highlighted in session list) */ async getActiveSessionName(window: Page): Promise { const activeSession = window.locator('[data-testid="session-item"].active').or( window.locator('[data-testid="session-item"][aria-selected="true"]') ); if (await activeSession.count() > 0) { return await activeSession.first().textContent(); } return null; }, /** * Get session count in the session list */ async getSessionCount(window: Page): Promise { const sessionItems = window.locator('[data-testid="session-item"]'); return await sessionItems.count(); }, /** * Wait for Auto Run content to change after session switch */ async waitForAutoRunContentChange(window: Page, previousContent: string, timeout = 5000): Promise { const textarea = window.locator('textarea'); try { await window.waitForFunction( (args) => { const ta = document.querySelector('textarea'); return ta && ta.value !== args.prev; }, { prev: previousContent }, { timeout } ); return true; } catch { return false; } }, /** * Create test folders for multiple sessions with unique content */ createMultiSessionTestFolders(basePath: string): { session1: string; session2: string } { const session1Path = path.join(basePath, 'session1', 'Auto Run Docs'); const session2Path = path.join(basePath, 'session2', 'Auto Run Docs'); fs.mkdirSync(session1Path, { recursive: true }); fs.mkdirSync(session2Path, { recursive: true }); // Session 1 documents fs.writeFileSync( path.join(session1Path, 'Session 1 Doc.md'), `# Session 1 Document ## Tasks - [ ] Session 1 Task A - [ ] Session 1 Task B - [x] Session 1 Completed Task ## Content Unique content for Session 1. ` ); // Session 2 documents fs.writeFileSync( path.join(session2Path, 'Session 2 Doc.md'), `# Session 2 Document ## Tasks - [ ] Session 2 Task X - [ ] Session 2 Task Y - [ ] Session 2 Task Z ## Content Unique content for Session 2. ` ); return { session1: session1Path, session2: session2Path }; }, /** * Verify Auto Run shows content specific to a session */ async verifyAutoRunSessionContent(window: Page, expectedSessionIdentifier: string): Promise { const textarea = window.locator('textarea'); if (await textarea.count() > 0) { const content = await textarea.inputValue(); return content.includes(expectedSessionIdentifier); } return false; }, /** * Get dirty state indicator (Save/Revert buttons visible) */ async isDirty(window: Page): Promise { const saveButton = window.locator('button').filter({ hasText: 'Save' }); const revertButton = window.locator('button').filter({ hasText: 'Revert' }); const saveVisible = await saveButton.count() > 0 && await saveButton.first().isVisible(); const revertVisible = await revertButton.count() > 0 && await revertButton.first().isVisible(); return saveVisible || revertVisible; }, /** * Save current Auto Run content */ async saveAutoRunContent(window: Page): Promise { const saveButton = window.locator('button').filter({ hasText: 'Save' }); if (await saveButton.count() > 0 && await saveButton.first().isVisible()) { await saveButton.first().click(); return true; } // Try keyboard shortcut await window.keyboard.press('Meta+S'); return true; }, /** * Revert Auto Run content to saved state */ async revertAutoRunContent(window: Page): Promise { const revertButton = window.locator('button').filter({ hasText: 'Revert' }); if (await revertButton.count() > 0 && await revertButton.first().isVisible()) { await revertButton.first().click(); return true; } return false; }, };