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 6b864c428b..a3fe91f425 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt @@ -41,6 +41,9 @@ class TestCommand : Callable { @Option(names = ["-c", "--continuous"]) private var continuous: Boolean = false + @Option(names = ["-e", "--env"]) + private var env: Map = emptyMap() + @CommandLine.Spec lateinit var commandSpec: CommandLine.Model.CommandSpec @@ -54,8 +57,8 @@ class TestCommand : Callable { val maestro = MaestroFactory.createMaestro(parent?.platform, parent?.host, parent?.port) - if (!continuous) return TestRunner.runSingle(maestro, flowFile) + if (!continuous) return TestRunner.runSingle(maestro, flowFile, env) - TestRunner.runContinuous(maestro, flowFile) + TestRunner.runContinuous(maestro, flowFile, env) } } 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 a161353288..c8982f3fa6 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt @@ -21,11 +21,9 @@ package maestro.cli.runner import maestro.Maestro import maestro.orchestra.MaestroCommand -import maestro.orchestra.MaestroInitFlow import maestro.orchestra.Orchestra import maestro.orchestra.OrchestraAppState import maestro.orchestra.yaml.YamlCommandReader -import java.io.File import java.util.IdentityHashMap object MaestroCommandRunner { @@ -34,6 +32,7 @@ object MaestroCommandRunner { maestro: Maestro, view: ResultView, commands: List, + env: Map, cachedAppState: OrchestraAppState?, ): Result { val initFlow = YamlCommandReader.getConfig(commands)?.initFlow @@ -84,14 +83,14 @@ object MaestroCommandRunner { val cachedState = if (cachedAppState == null) { initFlow?.let { - orchestra.runInitFlow(it) ?: return Result(flowSuccess = false, cachedAppState = null) + orchestra.runInitFlow(it, env = env) ?: return Result(flowSuccess = false, cachedAppState = null) } } else { initFlow?.commands?.forEach { commandStatuses[it] = CommandStatus.COMPLETED } cachedAppState } - val flowSuccess = orchestra.runFlow(commands, cachedState) + val flowSuccess = orchestra.runFlow(commands, cachedState, env = env) return Result(flowSuccess = flowSuccess, cachedAppState = cachedState) } diff --git a/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt b/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt index ec97dbad0e..8604214776 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt @@ -16,11 +16,18 @@ object TestRunner { fun runSingle( maestro: Maestro, flowFile: File, + env: Map, ): Int { val view = ResultView() val result = runCatching(view) { val commands = YamlCommandReader.readCommands(flowFile.toPath()) - MaestroCommandRunner.runCommands(maestro, view, commands, cachedAppState = null) + MaestroCommandRunner.runCommands( + maestro, + view, + commands, + env, + cachedAppState = null + ) } return if (result?.flowSuccess == true) 0 else 1 } @@ -28,6 +35,7 @@ object TestRunner { fun runContinuous( maestro: Maestro, flowFile: File, + env: Map, ): Nothing { val view = ResultView("> Press [ENTER] to restart the Flow\n\n") @@ -67,6 +75,7 @@ object TestRunner { maestro, view, commands, + env, cachedAppState = cachedAppState, ) } diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt index d793bb60e8..9845511541 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt @@ -20,11 +20,14 @@ package maestro.orchestra import maestro.Point +import maestro.orchestra.util.Env.injectEnv interface Command { fun description(): String + fun injectEnv(env: Map): Command + } data class SwipeCommand( @@ -36,6 +39,10 @@ data class SwipeCommand( return "Swipe from (${startPoint.x},${startPoint.y}) to (${endPoint.x},${endPoint.y})" } + override fun injectEnv(env: Map): SwipeCommand { + return this + } + } class ScrollCommand : Command { @@ -58,6 +65,10 @@ class ScrollCommand : Command { return "Scroll vertically" } + override fun injectEnv(env: Map): ScrollCommand { + return this + } + } class BackPressCommand : Command { @@ -80,6 +91,9 @@ class BackPressCommand : Command { return "Press back" } + override fun injectEnv(env: Map): BackPressCommand { + return this + } } data class TapOnElementCommand( @@ -92,6 +106,11 @@ data class TapOnElementCommand( return "Tap on ${selector.description()}" } + override fun injectEnv(env: Map): TapOnElementCommand { + return copy( + selector = selector.injectSecrets(env), + ) + } } data class TapOnPointCommand( @@ -104,6 +123,10 @@ data class TapOnPointCommand( override fun description(): String { return "Tap on point ($x, $y)" } + + override fun injectEnv(env: Map): TapOnPointCommand { + return this + } } data class AssertCommand( @@ -123,6 +146,12 @@ data class AssertCommand( return "No op" } + override fun injectEnv(env: Map): AssertCommand { + return copy( + visible = visible?.injectSecrets(env), + notVisible = notVisible?.injectSecrets(env), + ) + } } data class InputTextCommand( @@ -133,6 +162,11 @@ data class InputTextCommand( return "Input text $text" } + override fun injectEnv(env: Map): InputTextCommand { + return copy( + text = text.injectEnv(env) + ) + } } data class LaunchAppCommand( @@ -148,6 +182,9 @@ data class LaunchAppCommand( } } + override fun injectEnv(env: Map): LaunchAppCommand { + return this + } } data class ApplyConfigurationCommand( @@ -157,6 +194,10 @@ data class ApplyConfigurationCommand( override fun description(): String { return "Apply configuration" } + + override fun injectEnv(env: Map): ApplyConfigurationCommand { + return this + } } data class OpenLinkCommand( @@ -166,4 +207,10 @@ data class OpenLinkCommand( override fun description(): String { return "Open $link" } + + override fun injectEnv(env: Map): OpenLinkCommand { + return copy( + link = link.injectEnv(env), + ) + } } diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/ElementSelector.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/ElementSelector.kt index 22330378a1..b996077cfa 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/ElementSelector.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/ElementSelector.kt @@ -19,6 +19,8 @@ package maestro.orchestra +import maestro.orchestra.util.Env.injectEnv + data class ElementSelector( val textRegex: String? = null, val idRegex: String? = null, @@ -37,6 +39,22 @@ data class ElementSelector( val tolerance: Int? = null, ) + fun injectSecrets(env: Map): ElementSelector { + if (env.isEmpty()) { + return this + } + + return copy( + textRegex = textRegex?.injectEnv(env), + idRegex = idRegex?.injectEnv(env), + below = below?.injectSecrets(env), + above = above?.injectSecrets(env), + leftOf = leftOf?.injectSecrets(env), + rightOf = rightOf?.injectSecrets(env), + containsChild = containsChild?.injectSecrets(env), + ) + } + fun description(): String { val descriptions = mutableListOf() diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt new file mode 100644 index 0000000000..510e013092 --- /dev/null +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt @@ -0,0 +1,20 @@ +package maestro.orchestra.util + +object Env { + + fun String.injectEnv(env: Map): String { + if (env.isEmpty()) { + return this + } + + return env + .entries + .fold(this) { acc, (key, value) -> + acc.replace("(? + match.value.substringAfter('\\') + } + } + +} \ No newline at end of file diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt index 4aad395bcf..a7c7d4dc2c 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt @@ -44,10 +44,14 @@ class Orchestra( * If initState is provided, initialize app disk state with the provided OrchestraAppState and skip * any initFlow execution. Otherwise, initialize app state with initFlow if defined. */ - fun runFlow(commands: List, initState: OrchestraAppState? = null): Boolean { + fun runFlow( + commands: List, + initState: OrchestraAppState? = null, + env: Map = emptyMap(), + ): Boolean { val config = YamlCommandReader.getConfig(commands) val state = initState ?: config?.initFlow?.let { - runInitFlow(it) ?: return false + runInitFlow(it, env = env) ?: return false } if (state != null) { @@ -56,15 +60,22 @@ class Orchestra( } onFlowStart(commands) - return executeCommands(commands) + return executeCommands(commands, env) } /** * Run the initFlow and return the resulting app OrchestraAppState which can be used to initialize * app disk state when past into Orchestra.runFlow. */ - fun runInitFlow(initFlow: MaestroInitFlow): OrchestraAppState? { - val success = runFlow(initFlow.commands, initState = null) + fun runInitFlow( + initFlow: MaestroInitFlow, + env: Map = emptyMap(), + ): OrchestraAppState? { + val success = runFlow( + initFlow.commands, + initState = null, + env = env, + ) if (!success) return null maestro.stopApp(initFlow.appId) @@ -82,11 +93,14 @@ class Orchestra( ) } - private fun executeCommands(commands: List): Boolean { + private fun executeCommands( + commands: List, + env: Map + ): Boolean { commands.forEachIndexed { index, command -> onCommandStart(index, command) try { - executeCommand(command) + executeCommand(command, env) onCommandComplete(index, command) } catch (e: Throwable) { onCommandFailed(index, command, e) @@ -96,28 +110,45 @@ class Orchestra( return true } - private fun executeCommand(command: MaestroCommand) { + private fun executeCommand( + command: MaestroCommand, + env: Map + ) { when { - command.tapOnElement != null -> command.tapOnElement?.let { - tapOnElement( - it, - it.retryIfNoChange ?: true, - it.waitUntilVisible ?: true, - ) - } - command.tapOnPoint != null -> command.tapOnPoint?.let { - tapOnPoint( - it, - it.retryIfNoChange ?: true, - ) - } + command.tapOnElement != null -> command.tapOnElement + ?.injectEnv(env) + ?.let { + tapOnElement( + it, + it.retryIfNoChange ?: true, + it.waitUntilVisible ?: true, + ) + } + command.tapOnPoint != null -> command.tapOnPoint + ?.injectEnv(env) + ?.let { + tapOnPoint( + it, + it.retryIfNoChange ?: true, + ) + } command.backPressCommand != null -> maestro.backPress() command.scrollCommand != null -> maestro.scrollVertical() - command.swipeCommand != null -> command.swipeCommand?.let { swipeCommand(it) } - command.assertCommand != null -> command.assertCommand?.let { assertCommand(it) } - command.inputTextCommand != null -> command.inputTextCommand?.let { inputTextCommand(it) } - command.launchAppCommand != null -> command.launchAppCommand?.let { launchAppCommand(it) } - command.openLinkCommand != null -> command.openLinkCommand?.let { openLinkCommand(it) } + command.swipeCommand != null -> command.swipeCommand + ?.injectEnv(env) + ?.let { swipeCommand(it) } + command.assertCommand != null -> command.assertCommand + ?.injectEnv(env) + ?.let { assertCommand(it) } + command.inputTextCommand != null -> command.inputTextCommand + ?.injectEnv(env) + ?.let { inputTextCommand(it) } + command.launchAppCommand != null -> command.launchAppCommand + ?.injectEnv(env) + ?.let { launchAppCommand(it) } + command.openLinkCommand != null -> command.openLinkCommand + ?.injectEnv(env) + ?.let { openLinkCommand(it) } } } diff --git a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt index ceecd35504..79296c3ebb 100644 --- a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt +++ b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt @@ -740,6 +740,48 @@ class IntegrationTest { ) } + @Test + fun `Case 028 - Env`() { + // Given + val commands = readCommands("028_env") + + val driver = driver { + + element { + id = "button_id" + text = "button_text" + bounds = Bounds(0, 0, 100, 100) + } + + } + + // When + Maestro(driver).use { + orchestra(it).runFlow( + commands, + env = mapOf( + "BUTTON_ID" to "button_id", + "BUTTON_TEXT" to "button_text", + "PASSWORD" to "testPassword", + "NON_EXISTENT_TEXT" to "nonExistentText", + "NON_EXISTENT_ID" to "nonExistentId", + "URL" to "secretUrl", + ) + ) + } + + // Then + // No test failure + driver.assertEvents( + listOf( + Event.Tap(Point(50, 50)), + Event.Tap(Point(50, 50)), + Event.InputText("\${PASSWORD} is testPassword"), + Event.OpenLink("https://example.com/secretUrl") + ) + ) + } + private fun orchestra(it: Maestro) = Orchestra(it, lookupTimeoutMs = 0L, optionalLookupTimeoutMs = 0L) private fun driver(builder: FakeLayoutElement.() -> Unit): FakeDriver { diff --git a/maestro-test/src/test/resources/028_env.yaml b/maestro-test/src/test/resources/028_env.yaml new file mode 100644 index 0000000000..cd36a69d57 --- /dev/null +++ b/maestro-test/src/test/resources/028_env.yaml @@ -0,0 +1,18 @@ +appId: com.example.app +--- +- tapOn: + id: .*${BUTTON_ID}.* + retryTapIfNoChange: false +- tapOn: + text: .*${BUTTON_TEXT}.* + retryTapIfNoChange: false +- assertVisible: + text: .*${BUTTON_TEXT}.* +- assertVisible: + id: .*${BUTTON_ID}.* +- assertNotVisible: + text: .*${NON_EXISTENT_TEXT}.* +- assertNotVisible: + id: .*${NON_EXISTENT_ID}.* +- inputText: \${PASSWORD} is ${PASSWORD} +- openLink: https://example.com/${URL} \ No newline at end of file