From 56fbe3568c88068bd274f5230161708f133cdc47 Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Wed, 25 Mar 2026 13:55:09 +0200 Subject: [PATCH] Make appId optional in flow config --- .../orchestra/yaml/MaestroFlowParser.kt | 43 ++++-------------- .../java/maestro/orchestra/yaml/YamlConfig.kt | 16 +------ .../orchestra/yaml/YamlFluentCommand.kt | 35 ++++++++++----- .../orchestra/yaml/YamlCommandReaderTest.kt | 45 +++++++++++++++++++ .../031_launchApp_withoutAnyAppId.yaml | 3 ++ .../031_launchApp_withoutConfigAppId.yaml | 3 ++ .../031_openLink_withoutConfigAppId.yaml | 3 ++ .../e018_config_missing_appId/error.txt | 43 +++++++----------- .../workspace/Flow.yaml | 1 + .../kotlin/maestro/test/IntegrationTest.kt | 25 +++++++++++ .../139_launchApp_withoutConfigAppId.yaml | 3 ++ 11 files changed, 131 insertions(+), 89 deletions(-) create mode 100644 maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutAnyAppId.yaml create mode 100644 maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutConfigAppId.yaml create mode 100644 maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_openLink_withoutConfigAppId.yaml create mode 100644 maestro-test/src/test/resources/139_launchApp_withoutConfigAppId.yaml diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt index 5c1c9c56d7..5ddf1352eb 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt @@ -38,6 +38,7 @@ import maestro.orchestra.MaestroCommand import maestro.orchestra.WorkspaceConfig import maestro.orchestra.error.InvalidFlowFile import maestro.orchestra.error.MediaFileNotFound +import maestro.orchestra.error.SyntaxError import maestro.orchestra.util.Env.withEnv import org.intellij.lang.annotations.Language import java.nio.file.Path @@ -182,6 +183,13 @@ private fun wrapException(error: Throwable, parser: JsonParser, contentPath: Pat title = "Media File Not Found", errorMessage = e.cause.message, ) + is SyntaxError -> FlowParseException( + location = e.location, + contentPath = contentPath, + content = content, + title = "Parsing Failed", + errorMessage = e.cause.message, + ) else -> FlowParseException( location = e.location, contentPath = contentPath, @@ -201,41 +209,6 @@ private fun wrapException(error: Throwable, parser: JsonParser, contentPath: Pat docs = e.docs ) } - findException(error)?.let { e -> - return when (e.errorType) { - "missing_app_target" -> FlowParseException( - location = e.location ?: parser.currentLocation(), - contentPath = contentPath, - content = content, - title = "Config Field Required", - errorMessage = """ - |Either 'url' or 'appId' must be specified in the config section. - | - |For mobile apps, use: - |```yaml - |appId: com.example.app - |--- - |- launchApp - |``` - | - |For web apps, use: - |```yaml - |url: https://example.com - |--- - |- launchApp - |``` - """.trimMargin("|"), - docs = DOCS_FIRST_FLOW, - ) - else -> FlowParseException( - location = e.location ?: parser.currentLocation(), - contentPath = contentPath, - content = content, - title = "Config Parse Error", - errorMessage = "Unknown config validation error: ${e.errorType}", - ) - } - } findException(error)?.let { e -> return FlowParseException( location = e.location ?: parser.currentLocation(), diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlConfig.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlConfig.kt index 0d789d9fbc..4d1d8a44ff 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlConfig.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlConfig.kt @@ -2,7 +2,6 @@ package maestro.orchestra.yaml import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonAnySetter -import com.fasterxml.jackson.core.JsonLocation import maestro.orchestra.ApplyConfigurationCommand import maestro.orchestra.MaestroCommand import maestro.orchestra.MaestroConfig @@ -10,12 +9,6 @@ import maestro.orchestra.MaestroOnFlowComplete import maestro.orchestra.MaestroOnFlowStart import java.nio.file.Path -// Exception for config field validation errors -class ConfigParseError( - val errorType: String, - val location: JsonLocation? = null -) : RuntimeException("Config validation error: $errorType") - data class YamlConfig( val name: String?, @JsonAlias("appId") private val _appId: String?, @@ -31,14 +24,7 @@ data class YamlConfig( // Computed appId: uses url for web flows, _appId for mobile apps // Preserving both fields allows detecting web vs app configuration contexts - val appId: String - - init { - if (url == null && _appId == null) { - throw ConfigParseError("missing_app_target") - } - appId = url ?: _appId!! - } + val appId: String? = url ?: _appId @JsonAnySetter fun setOtherField(key: String, other: Any?) { diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt index f7d1543ca9..76e4b64a71 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt @@ -144,7 +144,7 @@ data class YamlFluentCommand( @JsonIgnore val _location: JsonLocation, ) { - fun toCommands(flowPath: Path, appId: String): List { + fun toCommands(flowPath: Path, appId: String?): List { return try { _toCommands(flowPath, appId) } catch (e: Throwable) { @@ -153,7 +153,7 @@ data class YamlFluentCommand( } @SuppressWarnings("ComplexMethod") - private fun _toCommands(flowPath: Path, appId: String): List { + private fun _toCommands(flowPath: Path, appId: String?): List { return when { launchApp != null -> listOf(launchApp(launchApp, appId)) setPermissions != null -> listOf(setPermissions(command = setPermissions, appId)) @@ -330,7 +330,7 @@ data class YamlFluentCommand( stopApp != null -> listOf( MaestroCommand( StopAppCommand( - appId = stopApp.appId ?: appId, + appId = requireAppId("stopApp", stopApp.appId, appId), label = stopApp.label, optional = stopApp.optional, ) @@ -340,7 +340,7 @@ data class YamlFluentCommand( killApp != null -> listOf( MaestroCommand( KillAppCommand( - appId = killApp.appId ?: appId, + appId = requireAppId("killApp", killApp.appId, appId), label = killApp.label, optional = killApp.optional, ) @@ -350,7 +350,7 @@ data class YamlFluentCommand( clearState != null -> listOf( MaestroCommand( ClearStateCommand( - appId = clearState.appId ?: appId, + appId = requireAppId("clearState", clearState.appId, appId), label = clearState.label, optional = clearState.optional, ) @@ -508,7 +508,7 @@ data class YamlFluentCommand( } private fun runFlowCommand( - appId: String, + appId: String?, flowPath: Path, runFlow: YamlRunFlow ): MaestroCommand { @@ -543,7 +543,7 @@ data class YamlFluentCommand( ) } - private fun retryCommand(retry: YamlRetryCommand, flowPath: Path, appId: String): MaestroCommand { + private fun retryCommand(retry: YamlRetryCommand, flowPath: Path, appId: String?): MaestroCommand { if (retry.file == null && retry.commands == null) { throw SyntaxError("Invalid retry command: No file or commands provided") } @@ -606,7 +606,7 @@ data class YamlFluentCommand( ) } - private fun repeatCommand(repeat: YamlRepeatCommand, flowPath: Path, appId: String) = MaestroCommand( + private fun repeatCommand(repeat: YamlRepeatCommand, flowPath: Path, appId: String?) = MaestroCommand( RepeatCommand( times = repeat.times, condition = repeat.`while`?.toCondition(), @@ -721,10 +721,10 @@ data class YamlFluentCommand( ) } - private fun launchApp(command: YamlLaunchApp, appId: String): MaestroCommand { + private fun launchApp(command: YamlLaunchApp, appId: String?): MaestroCommand { return MaestroCommand( LaunchAppCommand( - appId = command.appId ?: appId, + appId = requireAppId("launchApp", command.appId, appId), clearState = command.clearState, clearKeychain = command.clearKeychain, stopApp = command.stopApp, @@ -736,10 +736,10 @@ data class YamlFluentCommand( ) } - private fun setPermissions(command: YamlSetPermissions, appId: String): MaestroCommand { + private fun setPermissions(command: YamlSetPermissions, appId: String?): MaestroCommand { return MaestroCommand( SetPermissionsCommand( - appId = command.appId ?: appId, + appId = requireAppId("setPermissions", command.appId, appId), permissions = command.permissions, label = command.label, optional = command.optional, @@ -1002,6 +1002,17 @@ data class YamlFluentCommand( ) } + private fun requireAppId(commandName: String, commandAppId: String?, configAppId: String?): String { + return commandAppId ?: configAppId + ?: throw SyntaxError( + "No appId specified for '$commandName'. " + + "Provide it as a parameter or in the flow config:\n" + + "appId: com.example.app\n" + + "---\n" + + "- $commandName" + ) + } + private fun YamlCondition.toCondition(): Condition { return Condition( platform = platform, diff --git a/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt b/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt index a6105bcb47..53e8ab0b20 100644 --- a/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt +++ b/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt @@ -781,6 +781,51 @@ internal class YamlCommandReaderTest { } + @Test + fun openLink_withoutConfigAppId( + @YamlFile("031_openLink_withoutConfigAppId.yaml") commands: List + ) { + assertThat(commands).containsExactly( + ApplyConfigurationCommand(MaestroConfig( + name = "No appId flow", + )), + OpenLinkCommand( + link = "https://example.com", + autoVerify = false, + browser = false, + ), + ) + } + + @Test + fun launchApp_withoutConfigAppId( + @YamlFile("031_launchApp_withoutConfigAppId.yaml") commands: List + ) { + assertThat(commands).containsExactly( + ApplyConfigurationCommand(MaestroConfig( + name = "No appId flow", + )), + LaunchAppCommand( + appId = "com.other.app", + ), + ) + } + + @Test + fun launchApp_withoutAnyAppId_throws() { + val e = org.junit.jupiter.api.assertThrows { + YamlCommandReader.readCommands( + Paths.get(PROJECT_DIR, "src/test/resources/YamlCommandReaderTest/031_launchApp_withoutAnyAppId.yaml") + ) + } + assertThat(e.message).contains("No appId specified for 'launchApp'") + } + private fun commands(vararg commands: Command): List = commands.map(::MaestroCommand).toList() + + companion object { + private val PROJECT_DIR = System.getenv("PROJECT_DIR") + ?: throw RuntimeException("Unable to determine project directory") + } } diff --git a/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutAnyAppId.yaml b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutAnyAppId.yaml new file mode 100644 index 0000000000..3ba9a5d4e5 --- /dev/null +++ b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutAnyAppId.yaml @@ -0,0 +1,3 @@ +name: No appId flow +--- +- launchApp diff --git a/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutConfigAppId.yaml b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutConfigAppId.yaml new file mode 100644 index 0000000000..325f3831b4 --- /dev/null +++ b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_launchApp_withoutConfigAppId.yaml @@ -0,0 +1,3 @@ +name: No appId flow +--- +- launchApp: com.other.app diff --git a/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_openLink_withoutConfigAppId.yaml b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_openLink_withoutConfigAppId.yaml new file mode 100644 index 0000000000..ef6713b818 --- /dev/null +++ b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_openLink_withoutConfigAppId.yaml @@ -0,0 +1,3 @@ +name: No appId flow +--- +- openLink: https://example.com diff --git a/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/error.txt b/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/error.txt index e4640aa024..be7e7b6e11 100644 --- a/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/error.txt +++ b/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/error.txt @@ -1,28 +1,17 @@ -> Config Field Required +> Parsing Failed -/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:2 -╭────────────────────────────────────────────────────────────────────────╮ -│ 1 | name: MyFlow │ -│ 2 | --- │ -│ ^ │ -│ ╭────────────────────────────────────────────────────────────────────╮ │ -│ │ Either 'url' or 'appId' must be specified in the config section. │ │ -│ │ │ │ -│ │ For mobile apps, use: │ │ -│ │ ```yaml │ │ -│ │ appId: com.example.app │ │ -│ │ --- │ │ -│ │ - launchApp │ │ -│ │ ``` │ │ -│ │ │ │ -│ │ For web apps, use: │ │ -│ │ ```yaml │ │ -│ │ url: https://example.com │ │ -│ │ --- │ │ -│ │ - launchApp │ │ -│ │ ``` │ │ -│ │ │ │ -│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow │ │ -│ ╰────────────────────────────────────────────────────────────────────╯ │ -│ 3 | │ -╰────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3 +╭──────────────────────────────────────────────────────────────────────────────────╮ +│ 1 | name: MyFlow │ +│ 2 | --- │ +│ 3 | - launchApp │ +│ ^ │ +│ ╭──────────────────────────────────────────────────────────────────────────────╮ │ +│ │ No appId specified for 'launchApp'. Provide it as a parameter or in the flow │ │ +│ │ config: │ │ +│ │ appId: com.example.app │ │ +│ │ --- │ │ +│ │ - launchApp │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────╯ │ +│ 4 | │ +╰──────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/workspace/Flow.yaml b/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/workspace/Flow.yaml index f534a50887..3b23714b88 100644 --- a/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/workspace/Flow.yaml +++ b/maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/workspace/Flow.yaml @@ -1,2 +1,3 @@ name: MyFlow --- +- launchApp diff --git a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt index 8b0a0a9f7d..2eddab1a02 100644 --- a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt +++ b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt @@ -4575,6 +4575,31 @@ class IntegrationTest { assertThat(closeCalled).isTrue() } + @Test + fun `Case 139 - Launch app without config appId`() { + // Given + val commands = readCommands("139_launchApp_withoutConfigAppId") + + val driver = driver { + } + driver.addInstalledApp("com.example.app") + + // When + Maestro(driver).use { + runBlocking { + orchestra(it).runFlow(commands) + } + } + + // Then + driver.assertEvents( + listOf( + Event.StopApp("com.example.app"), + Event.LaunchApp("com.example.app") + ) + ) + } + private fun readCommands( caseName: String, deviceId: String? = null, diff --git a/maestro-test/src/test/resources/139_launchApp_withoutConfigAppId.yaml b/maestro-test/src/test/resources/139_launchApp_withoutConfigAppId.yaml new file mode 100644 index 0000000000..4c9bb83932 --- /dev/null +++ b/maestro-test/src/test/resources/139_launchApp_withoutConfigAppId.yaml @@ -0,0 +1,3 @@ +name: No appId flow +--- +- launchApp: com.example.app