Skip to main content
Glama
maestro-executor.ts8.29 kB
/** * Maestro Flow Executor * Runs Maestro E2E test flows and captures results */ import { existsSync } from 'fs'; import { basename } from 'path'; import { executeShell } from '../../utils/shell.js'; import { FlowResult, FlowStep } from '../../models/failure-bundle.js'; import { Platform } from '../../models/constants.js'; /** * Options for running Maestro flows */ export interface MaestroRunOptions { /** Path to the flow YAML file */ flowPath: string; /** Target platform */ platform: Platform; /** Device ID */ deviceId?: string; /** App package/bundle ID */ appId?: string; /** Timeout for the entire flow in milliseconds */ timeoutMs?: number; /** Output format (Maestro supports: JUNIT, HTML, NOOP) */ format?: 'junit' | 'html' | 'noop'; /** Output path for reports */ outputPath?: string; /** Environment variables for the flow */ env?: Record<string, string>; } /** * Run a Maestro flow */ export async function runMaestroFlow(options: MaestroRunOptions): Promise<FlowResult> { const { flowPath, platform, deviceId, appId, timeoutMs = 300000, // 5 minutes format, outputPath, env = {}, } = options; // Validate flow file exists if (!existsSync(flowPath)) { return { flowName: basename(flowPath), flowPath, success: false, totalSteps: 0, passedSteps: 0, failedAtStep: -1, durationMs: 0, steps: [], error: `Flow file not found: ${flowPath}`, }; } const startTime = Date.now(); const flowName = basename(flowPath); // Build Maestro command - global options must come BEFORE 'test' subcommand const args: string[] = []; // Add global device selection (must come before 'test') if (deviceId) { args.push('--device', deviceId); } // Add global platform option (must come before 'test') args.push('--platform', platform); // Now add the test subcommand and flow path args.push('test'); // Add app ID as environment variable via -e flag (required for Maestro) if (appId) { args.push('-e', `APP_ID=${appId}`); } // Add custom env vars for (const [key, value] of Object.entries(env)) { args.push('-e', `${key}=${value}`); } // Add format and output (Maestro supports: JUNIT, HTML, NOOP) if (format) { args.push('--format', format.toUpperCase()); } if (outputPath) { args.push('--output', outputPath); } // Add flow path last args.push(flowPath); // Build environment const envVars: Record<string, string> = { ...process.env as Record<string, string>, }; // Run Maestro const result = await executeShell('maestro', args, { timeoutMs, silent: false, env: envVars, }); const durationMs = Date.now() - startTime; // Build FlowResult by parsing steps from raw output const steps: FlowStep[] = []; let failedAtStep = -1; let passedSteps = 0; // Parse steps from stdout const parsedSteps = parseStepsFromOutput(result.stdout); for (let i = 0; i < parsedSteps.length; i++) { const step = parsedSteps[i]; if (step.status === 'passed') passedSteps++; if (step.status === 'failed' && failedAtStep === -1) failedAtStep = i; steps.push(step); } // Success is determined primarily by exit code // - Exit code 0 means Maestro completed successfully // - Step parsing is for detailed reporting only (Maestro output format may vary) const success = result.exitCode === 0 && failedAtStep === -1; return { flowName, flowPath, success, totalSteps: steps.length, passedSteps, failedAtStep, durationMs, steps, error: !success ? (result.stderr || result.stdout || 'Flow execution failed') : undefined, }; } /** * Parse flow steps from Maestro raw output * Supports Maestro 2.x output format: * - "Tap on "+"... COMPLETED" * - "Assert that "1" is visible... FAILED" * - "Launch app "com.example"... COMPLETED" */ function parseStepsFromOutput(output: string): FlowStep[] { const steps: FlowStep[] = []; const lines = output.split('\n'); let stepIndex = 0; for (const line of lines) { // Maestro 2.x format: "Action description... STATUS" // Examples: // "Tap on "+"... COMPLETED" // "Assert that "Specter Counter" is visible... COMPLETED" // "Launch app "${APP_ID}"... COMPLETED" const maestro2Match = line.match(/^(.+)\.\.\.\s+(COMPLETED|FAILED|SKIPPED|RUNNING)$/); if (maestro2Match) { const [, description, status] = maestro2Match; const normalizedStatus = status === 'COMPLETED' ? 'passed' : status === 'FAILED' ? 'failed' : status === 'SKIPPED' ? 'skipped' : 'passed'; // Parse command from description (e.g., "Tap on" -> "tapOn", "Assert that" -> "assertVisible") const command = parseCommandFromDescription(description); steps.push({ index: stepIndex++, command, args: { description: description.trim() }, status: normalizedStatus, durationMs: 0, // Maestro 2.x doesn't show duration per step error: status === 'FAILED' ? description : undefined, }); continue; } // Legacy format support (Maestro 1.x): // "✓ tapOn: Login button (1.2s)" // "✗ assertVisible: Welcome text (timeout)" const passMatch = line.match(/[✓✅]\s+(\w+)(?::\s+(.+))?\s+\(([^)]+)\)/); const failMatch = line.match(/[✗❌]\s+(\w+)(?::\s+(.+))?\s+\(([^)]+)\)/); const skipMatch = line.match(/[⊘○]\s+(\w+)(?::\s+(.+))?/); if (passMatch) { steps.push({ index: stepIndex++, command: passMatch[1], args: passMatch[2] ? { target: passMatch[2] } : {}, status: 'passed', durationMs: parseDuration(passMatch[3]), }); } else if (failMatch) { steps.push({ index: stepIndex++, command: failMatch[1], args: failMatch[2] ? { target: failMatch[2] } : {}, status: 'failed', durationMs: parseDuration(failMatch[3]), error: failMatch[3], }); } else if (skipMatch) { steps.push({ index: stepIndex++, command: skipMatch[1], args: skipMatch[2] ? { target: skipMatch[2] } : {}, status: 'skipped', durationMs: 0, }); } } return steps; } /** * Parse Maestro command from description text */ function parseCommandFromDescription(description: string): string { const lower = description.toLowerCase(); if (lower.startsWith('tap on')) return 'tapOn'; if (lower.startsWith('assert that') && lower.includes('visible')) return 'assertVisible'; if (lower.startsWith('assert that')) return 'assert'; if (lower.startsWith('launch app')) return 'launchApp'; if (lower.startsWith('input text')) return 'inputText'; if (lower.startsWith('swipe')) return 'swipe'; if (lower.startsWith('scroll')) return 'scroll'; if (lower.startsWith('wait')) return 'wait'; if (lower.startsWith('back')) return 'back'; if (lower.startsWith('hide keyboard')) return 'hideKeyboard'; if (lower.startsWith('open link')) return 'openLink'; if (lower.startsWith('take screenshot')) return 'takeScreenshot'; return 'command'; } /** * Parse duration string to milliseconds */ function parseDuration(durationStr: string): number { const match = durationStr.match(/([\d.]+)\s*(s|ms|m)?/); if (!match) return 0; const value = parseFloat(match[1]); const unit = match[2] || 's'; switch (unit) { case 'ms': return value; case 's': return value * 1000; case 'm': return value * 60000; default: return value * 1000; } } /** * Check if Maestro CLI is available */ export async function isMaestroAvailable(): Promise<boolean> { try { const result = await executeShell('maestro', ['--version'], { timeoutMs: 5000, silent: true, }); return result.exitCode === 0; } catch { return false; } } /** * Get Maestro version */ export async function getMaestroVersion(): Promise<string | null> { try { const result = await executeShell('maestro', ['--version'], { timeoutMs: 5000, silent: true, }); if (result.exitCode === 0) { return result.stdout.trim(); } } catch { // Maestro not available } return null; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/abd3lraouf/specter-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server