Skip to content
2 changes: 2 additions & 0 deletions maestro-client/src/main/java/maestro/Errors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ sealed class MaestroException(override val message: String, cause: Throwable? =
class MissingAppleTeamId(message: String, cause: Throwable? = null): MaestroException(message, cause)

class IOSDeviceDriverSetupException(message: String, cause: Throwable? = null): MaestroException(message, cause)

class CommandLineException(message: String, cause: Throwable? = null) : MaestroException(message, cause)
}

sealed class MaestroDriverStartupException(override val message: String, cause: Throwable? = null): RuntimeException(message, cause) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,34 @@ data class RunScriptCommand(
}
}

data class RunShellCommand(
val command: String,
val env: Map<String, String> = emptyMap(),
val workingDirectory: String? = null,
val condition: Condition?,
override val label: String? = null,
override val optional: Boolean = false,
val outputVariable: String? = null,
val timeout: Long? = null,
) : Command {

override val originalDescription: String
get() = if (condition == null) {
"Run shell command $command" +
if (workingDirectory != null) " in working directory $workingDirectory" else ""
} else {
"Run shell command $command" +
" when ${condition.description()}"
}

override fun evaluateScripts(jsEngine: JsEngine): Command {
return copy(
env = env.mapValues { (_, value) -> value.evaluateScripts(jsEngine) },
condition = condition?.evaluateScripts(jsEngine),
)
}
}

data class WaitForAnimationToEndCommand(
val timeout: Long?,
override val label: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ data class MaestroCommand(
val pasteTextCommand: PasteTextCommand? = null,
val defineVariablesCommand: DefineVariablesCommand? = null,
val runScriptCommand: RunScriptCommand? = null,
val runShellCommand: RunShellCommand? = null,
val waitForAnimationToEndCommand: WaitForAnimationToEndCommand? = null,
val evalScriptCommand: EvalScriptCommand? = null,
val scrollUntilVisible: ScrollUntilVisibleCommand? = null,
Expand Down Expand Up @@ -105,6 +106,7 @@ data class MaestroCommand(
pasteTextCommand = command as? PasteTextCommand,
defineVariablesCommand = command as? DefineVariablesCommand,
runScriptCommand = command as? RunScriptCommand,
runShellCommand = command as? RunShellCommand,
waitForAnimationToEndCommand = command as? WaitForAnimationToEndCommand,
evalScriptCommand = command as? EvalScriptCommand,
scrollUntilVisible = command as? ScrollUntilVisibleCommand,
Expand Down Expand Up @@ -150,6 +152,7 @@ data class MaestroCommand(
pasteTextCommand != null -> pasteTextCommand
defineVariablesCommand != null -> defineVariablesCommand
runScriptCommand != null -> runScriptCommand
runShellCommand != null -> runShellCommand
waitForAnimationToEndCommand != null -> waitForAnimationToEndCommand
evalScriptCommand != null -> evalScriptCommand
scrollUntilVisible != null -> scrollUntilVisible
Expand Down
46 changes: 40 additions & 6 deletions maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@

package maestro.orchestra

import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.runBlocking
import maestro.Driver
import maestro.ElementFilter
import maestro.Filters
Expand All @@ -31,11 +29,7 @@ import maestro.Maestro
import maestro.MaestroException
import maestro.ScreenRecording
import maestro.ViewHierarchy
import maestro.ai.AI
import maestro.ai.AI.Companion.AI_KEY_ENV_VAR
import maestro.ai.anthropic.Claude
import maestro.ai.cloud.Defect
import maestro.ai.openai.OpenAI
import maestro.ai.CloudAIPredictionEngine
import maestro.ai.AIPredictionEngine
import maestro.js.GraalJsEngine
Expand All @@ -53,6 +47,7 @@ import maestro.utils.Insights
import maestro.utils.MaestroTimer
import maestro.utils.NoopInsights
import maestro.utils.StringUtils.toRegexSafe
import maestro.utils.CommandLine
import okhttp3.OkHttpClient
import okio.Buffer
import okio.Sink
Expand All @@ -63,6 +58,7 @@ import java.io.File
import java.lang.Long.max
import kotlin.coroutines.coroutineContext


// TODO(bartkepacia): Use this in onCommandGeneratedOutput.
// Caveat:
// Large files should not be held in memory, instead they should be directly written to a Buffer
Expand Down Expand Up @@ -358,6 +354,7 @@ class Orchestra(
is RepeatCommand -> repeatCommand(command, maestroCommand, config)
is DefineVariablesCommand -> defineVariablesCommand(command)
is RunScriptCommand -> runScriptCommand(command)
is RunShellCommand -> runShellCommand(command)
is EvalScriptCommand -> evalScriptCommand(command)
is ApplyConfigurationCommand -> false
is WaitForAnimationToEndCommand -> waitForAnimationToEndCommand(command)
Expand Down Expand Up @@ -547,6 +544,43 @@ class Orchestra(
}
}

fun runShellCommand(command: RunShellCommand): Boolean {
return if (evaluateCondition(command.condition, commandOptional = command.optional)) {
try {

val process = CommandLine.runCommand(
command = command.command,
workingDirectory = command.workingDirectory,
environment = command.env,
timeout = command.timeout
)

val exitCode = process.exitValue()
if (exitCode != 0 && !command.optional) {
val errorMessage = process.errorStream.bufferedReader().readText()
throw MaestroException.CommandLineException(
"Shell command '${command.command}' failed with exit code $exitCode: $errorMessage"
)
}
val output = process.inputStream.bufferedReader().readText()
jsEngine.putEnv(command.outputVariable ?: "COMMAND_LINE_OUTPUT", CommandLine.escapeCommandLineOutput(output))
true
} catch (e: Exception) {
if (command.optional) {
logger.warn("Optional shell command '${command.command}' failed: ${e.message}")
false
} else {
throw MaestroException.CommandLineException(
"Failed to execute shell command '${command.command}' with error: ${e.message}",
e
)
}
}
} else {
throw CommandSkipped
}
}

private fun waitForAnimationToEndCommand(command: WaitForAnimationToEndCommand): Boolean {
maestro.waitForAnimationToEnd(command.timeout)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import maestro.orchestra.RepeatCommand
import maestro.orchestra.RetryCommand
import maestro.orchestra.RunFlowCommand
import maestro.orchestra.RunScriptCommand
import maestro.orchestra.RunShellCommand
import maestro.orchestra.ScrollCommand
import maestro.orchestra.ScrollUntilVisibleCommand
import maestro.orchestra.SetAirplaneModeCommand
Expand Down Expand Up @@ -123,6 +124,7 @@ data class YamlFluentCommand(
val repeat: YamlRepeatCommand? = null,
val copyTextFrom: YamlElementSelectorUnion? = null,
val runScript: YamlRunScript? = null,
val runShellCommand: YamlRunShellCommand? = null,
val waitForAnimationToEnd: YamlWaitForAnimationToEndCommand? = null,
val evalScript: YamlEvalScript? = null,
val scrollUntilVisible: YamlScrollUntilVisible? = null,
Expand Down Expand Up @@ -424,6 +426,21 @@ data class YamlFluentCommand(
)
)

runShellCommand != null -> listOf(
MaestroCommand(
RunShellCommand(
command = runShellCommand.command,
env = runShellCommand.env ?: emptyMap(),
condition = runShellCommand.`when`?.toCondition(),
workingDirectory = runShellCommand.workingDirectory,
label = runShellCommand.label,
optional = runShellCommand.optional,
outputVariable = runShellCommand.outputVariable,
timeout = runShellCommand.timeout,
)
)
)

waitForAnimationToEnd != null -> listOf(
MaestroCommand(
WaitForAnimationToEndCommand(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package maestro.orchestra.yaml

import com.fasterxml.jackson.annotation.JsonCreator

data class YamlRunShellCommand(
val command: String,
val env: Map<String, String>? = null,
val `when`: YamlCondition? = null,
val workingDirectory: String? = null,
val label: String? = null,
val optional: Boolean = false,
val outputVariable: String? = null,
val timeout: Long? = null,
) {

companion object {

@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun parse(command: String) = YamlRunShellCommand(
command = command,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import maestro.orchestra.PressKeyCommand
import maestro.orchestra.RepeatCommand
import maestro.orchestra.RunFlowCommand
import maestro.orchestra.RunScriptCommand
import maestro.orchestra.RunShellCommand
import maestro.orchestra.SetOrientationCommand
import maestro.orchestra.ScrollCommand
import maestro.orchestra.ScrollUntilVisibleCommand
Expand Down Expand Up @@ -586,6 +587,25 @@ internal class YamlCommandReaderTest {
)
}

@Test
fun runShellCommand(
@YamlFile("029_runShellCommand.yaml") commands: List<Command>
) {
assertThat(commands).containsExactly(
ApplyConfigurationCommand(MaestroConfig(
appId = "com.example.app"
)),
RunShellCommand(
command = "echo Hello, world!",
outputVariable = "SHELL_OUTPUT",
timeout = 1000L,
optional = false,
label = "Run echo command",
condition = null,
)
)
}

private fun commands(vararg commands: Command): List<MaestroCommand> =
commands.map(::MaestroCommand).toList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
appId: com.example.app
---
- runShellCommand:
command: "echo Hello, world!"
outputVariable: "SHELL_OUTPUT"
timeout: 1000
label: "Run echo command"
132 changes: 132 additions & 0 deletions maestro-utils/src/main/kotlin/CommandLine.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
*
* Copyright (c) 2022 mobile.dev inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*
*/

package maestro.utils

import java.io.File
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

object CommandLine {

/**
* Runs a shell command with optional arguments, working directory, environment, and timeout.
*
* @param command The command to run, as a string or list of arguments.
* @param args Optional list of arguments (if command is a program name).
* @param workingDirectory The working directory for the command, or null for current.
* @param environment Environment variables to set, or null for default.
* @param timeout Timeout in milliseconds, or null for no timeout.
* @return The started [Process].
* @throws TimeoutException If the process times out.
* @throws Exception If the process fails to start.
*/
fun runCommand(
command: String,
args: List<String>? = null,
workingDirectory: String? = null,
environment: Map<String, String>? = null,
timeout: Long? = null,
): Process {
val commandList =
when {
args != null -> parseCommand(command) + args
else -> parseCommand(command)
}
val commandString = commandList.joinToString(" ")
val processBuilder =
ProcessBuilder(commandList).apply {
workingDirectory?.let { directory(File(it)) }
environment?.let { environment().putAll(it) }
redirectErrorStream(false)
}
val process =
try {
processBuilder.start()
} catch (e: Exception) {
throw Exception("Failed to start command: $commandString", e)
}

if (timeout != null) {
if (!process.waitFor(timeout, TimeUnit.MILLISECONDS)) {
process.destroyForcibly()
throw TimeoutException("Command '$commandString' timed out after $timeout ms")
}
} else {
process.waitFor()
}

return process
}

/**
* Parses a command string into a list of arguments, handling quotes and escapes. Trims input
* and returns an empty list for blank/null input.
*/
fun parseCommand(cmd: String?): List<String> {
val line = cmd?.trim().orEmpty()
if (line.isEmpty()) return emptyList()

val args = mutableListOf<String>()
val current = StringBuilder()
var inSingleQuote = false
var inDoubleQuote = false
var escape = false

line.forEach { c ->
when {
escape -> {
current.append(c)
escape = false
}
c == '\\' -> escape = true
c == '"' && !inSingleQuote -> inDoubleQuote = !inDoubleQuote
c == '\'' && !inDoubleQuote -> inSingleQuote = !inSingleQuote
c.isWhitespace() && !inSingleQuote && !inDoubleQuote -> {
if (current.isNotEmpty()) {
args += current.toString()
current.clear()
}
}
else -> current.append(c)
}
}
if (current.isNotEmpty()) {
args += current.toString()
}
return args
}

/**
* Escapes command line output for safe use in JavaScript and trims trailing whitespace.
* - Escapes backslashes and double quotes.
* - Converts newlines to \n.
* - Removes carriage returns.
* - Trims trailing whitespace.
*/
fun escapeCommandLineOutput(output: String): String {
return output.replace("\r\n", "\n") // Normalize Windows newlines
.replace("\r", "\n") // Normalize old Mac newlines
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace(Regex("(\\\\n)+$"), "") // Remove trailing \n sequences
.trim()
}
}
Loading
Loading