diff --git a/maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt b/maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt index 57772b532f..1aabdd029c 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt @@ -25,7 +25,7 @@ import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.api.ApiClient import maestro.cli.report.TestDebugReporter -import maestro.cli.runner.TestRunner +import maestro.cli.runner.MaestroFlowRunner import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.session.MaestroSessionManager import maestro.cli.view.ProgressBar @@ -104,7 +104,7 @@ class RecordCommand : Callable { val screenRecording = kotlin.io.path.createTempFile(suffix = ".mp4").toFile() val exitCode = screenRecording.sink().use { out -> maestro.startScreenRecording(out).use { - TestRunner.runSingle(maestro, device, flowFile, env, resultView, path) + MaestroFlowRunner.runSingle(maestro, device, flowFile, env, resultView, path) } } diff --git a/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt b/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt index 54898d33ff..14eeb56358 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt @@ -37,7 +37,7 @@ import maestro.cli.model.TestExecutionSummary import maestro.cli.report.ReportFormat import maestro.cli.report.ReporterFactory import maestro.cli.report.TestDebugReporter -import maestro.cli.runner.TestRunner +import maestro.cli.runner.MaestroFlowRunner import maestro.cli.runner.TestSuiteInteractor import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.runner.resultview.PlainTextResultView @@ -297,12 +297,13 @@ class TestCommand : Callable { if (!flattenDebugOutput) { TestDebugReporter.deleteOldFiles() } - TestRunner.runContinuous(maestro, device, flowFile, env) + MaestroFlowRunner.runContinuous(maestro, device, flowFile, env) + } else { val resultView = if (DisableAnsiMixin.ansiEnabled) AnsiResultView() else PlainTextResultView() - val resultSingle = TestRunner.runSingle( + val resultSingle = MaestroFlowRunner.runSingle( maestro, device, flowFile, diff --git a/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt b/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt index 8fac330fbf..ef4e264934 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt @@ -36,10 +36,16 @@ import maestro.orchestra.OrchestraAppState import maestro.orchestra.yaml.YamlCommandReader import maestro.utils.Insight import okio.Buffer +import okio.sink import org.slf4j.LoggerFactory import java.io.File import java.util.IdentityHashMap +/** + * Knows how to run a list of Maestro commands and update the UI. + * + * Should not know what a "flow" is. + */ object MaestroCommandRunner { private val logger = LoggerFactory.getLogger(MaestroCommandRunner::class.java) @@ -72,7 +78,7 @@ object MaestroCommandRunner { val out = File .createTempFile("screenshot-${System.currentTimeMillis()}", ".png") .also { it.deleteOnExit() } // save to another dir before exiting - maestro.takeScreenshot(out, false) + maestro.takeScreenshot(out.sink(), false) debugOutput.screenshots.add( FlowDebugOutput.Screenshot( screenshot = out, @@ -125,7 +131,7 @@ object MaestroCommandRunner { refreshUi() val orchestra = Orchestra( - maestro, + maestro = maestro, onCommandStart = { _, command -> logger.info("${command.description()} RUNNING") commandStatuses[command] = CommandStatus.RUNNING diff --git a/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt b/maestro-cli/src/main/java/maestro/cli/runner/MaestroFlowRunner.kt similarity index 83% rename from maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt rename to maestro-cli/src/main/java/maestro/cli/runner/MaestroFlowRunner.kt index 2aafed0eb2..18d03dd602 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/MaestroFlowRunner.kt @@ -26,10 +26,18 @@ import java.io.File import java.nio.file.Path import kotlin.concurrent.thread -object TestRunner { - - private val logger = LoggerFactory.getLogger(TestRunner::class.java) - +/** + * Knows how to run a single Maestro flow (either one-shot or continuously). + */ +object MaestroFlowRunner { + + private val logger = LoggerFactory.getLogger(MaestroFlowRunner::class.java) + + /** + * Runs a single flow, one-shot style. + * + * If the flow generates artifacts, they should be placed in [debugOutputPath]. + */ fun runSingle( maestro: Maestro, device: Device?, @@ -53,12 +61,12 @@ object TestRunner { } MaestroCommandRunner.runCommands( - maestro, - device, - resultView, - commands, - debugOutput, - aiOutput, + maestro = maestro, + device = device, + view = resultView, + commands = commands, + debugOutput = debugOutput, + aiOutput = aiOutput, ) } @@ -77,6 +85,9 @@ object TestRunner { return if (result.get()?.flowSuccess == true) 0 else 1 } + /** + * Runs a single flow continuously. + */ fun runContinuous( maestro: Maestro, device: Device?, @@ -119,13 +130,13 @@ object TestRunner { previousResult = runCatching(resultView, maestro) { MaestroCommandRunner.runCommands( - maestro, - device, - resultView, - commands, - FlowDebugOutput(), - // TODO: bartekpacia - make AI outputs work in continuous mode - FlowAIOutput( + maestro = maestro, + device = device, + view = resultView, + commands = commands, + debugOutput = FlowDebugOutput(), + // TODO(bartekpacia): make AI outputs work in continuous mode + aiOutput = FlowAIOutput( flowName = "TODO", flowFile = flowFile, ), diff --git a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt index 07529de30f..ce3f8ddaaf 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt @@ -10,7 +10,6 @@ import maestro.cli.report.SingleScreenFlowAIOutput import maestro.cli.report.CommandDebugMetadata import maestro.cli.report.FlowAIOutput import maestro.cli.report.FlowDebugOutput -import maestro.cli.report.HtmlAITestSuiteReporter import maestro.cli.report.TestDebugReporter import maestro.cli.report.TestSuiteReporter import maestro.cli.util.PrintUtils @@ -31,7 +30,7 @@ import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds /** - * Similar to [TestRunner], but: + * Similar to [MaestroFlowRunner], but: * * can run many flows at once * * does not support continuous mode */ @@ -233,7 +232,6 @@ class TestSuiteInteractor( it.status = CommandStatus.PENDING } }, - // another name idea: onCommandFoundDefects onCommandGeneratedOutput = { command, defects, screenshot -> logger.info("${command.description()} generated output") val screenshotPath = writeAIscreenshot(screenshot) diff --git a/maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt b/maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt index cb71ba7d86..b5710665b5 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt @@ -23,7 +23,6 @@ import io.ktor.util.encodeBase64 import maestro.cli.runner.CommandState import maestro.cli.runner.CommandStatus import maestro.utils.Insight -import maestro.utils.Insights import maestro.utils.chunkStringByWordCount import org.fusesource.jansi.Ansi diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt index 4497c18abe..66c1daac47 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt @@ -22,8 +22,8 @@ package maestro.orchestra import kotlinx.coroutines.runBlocking import maestro.* import maestro.Filters.asFilter -import maestro.ai.Defect import maestro.ai.AI +import maestro.ai.Defect import maestro.ai.Prediction import maestro.ai.openai.OpenAI import maestro.js.GraalJsEngine @@ -43,6 +43,7 @@ import okhttp3.OkHttpClient import okio.Buffer import okio.buffer import okio.sink +import okio.Sink import java.io.File import java.lang.Long.max import java.nio.file.Files @@ -59,10 +60,19 @@ sealed class CommandOutput { data class AIDefects(val defects: List, val screenshot: Buffer) : CommandOutput() } +/** + * Orchestra translates high-level Maestro commands into method calls on the [Maestro] object. + * It's the glue between the CLI and platform-specific [Driver]s (encapsulated in the [Maestro] object). + * It's one of the core classes in this codebase. + * + * Orchestra should not know about: + * - Specific platforms where tests can be executed, such as Android, iOS, or the web. + * - File systems. It should instead write to [Sink]s that it requests from the caller. + */ class Orchestra( private val maestro: Maestro, private val stateDir: File? = null, - private val screenshotsDir: File? = null, + private val screenshotsDir: File? = null, // TODO(bartekpacia): Orchestra shouldn't interact with files directly. private val lookupTimeoutMs: Long = 17000L, private val optionalLookupTimeoutMs: Long = 7000L, private val httpClient: OkHttpClient? = null, @@ -223,7 +233,6 @@ class Orchestra( onCommandComplete(index, command) } catch (ignored: CommandSkipped) { // Swallow exception - println("ignored CommandSkipped") onCommandSkipped(index, command) } catch (e: Throwable) { @@ -379,7 +388,7 @@ class Orchestra( aiClient = ai, assertion = command.assertion, screen = imageData.copy().readByteArray(), - previousFalsePositives = listOf(), // TODO: take it from WorkspaceConfig (or MaestroConfig?) + previousFalsePositives = listOf(), // TODO(bartekpacia): take it from WorkspaceConfig (or MaestroConfig?) ) if (defects.isNotEmpty()) { @@ -387,8 +396,9 @@ class Orchestra( if (command.optional) throw CommandSkipped + val word = if (defects.size == 1) "defect" else "defects" throw MaestroException.AssertionFailure( - "Visual AI found possible defects:\n ${defects.joinToString("\n ") { "${it.category}: ${it.reasoning}" }}", + "Visual AI found ${defects.size} possible $word. See the report to learn more.", maestro.viewHierarchy().root, ) }