Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -201,41 +209,6 @@ private fun wrapException(error: Throwable, parser: JsonParser, contentPath: Pat
docs = e.docs
)
}
findException<ConfigParseError>(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<MissingKotlinParameterException>(error)?.let { e ->
return FlowParseException(
location = e.location ?: parser.currentLocation(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,13 @@ 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
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?,
Expand All @@ -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?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ data class YamlFluentCommand(
@JsonIgnore val _location: JsonLocation,
) {

fun toCommands(flowPath: Path, appId: String): List<MaestroCommand> {
fun toCommands(flowPath: Path, appId: String?): List<MaestroCommand> {
return try {
_toCommands(flowPath, appId)
} catch (e: Throwable) {
Expand All @@ -153,7 +153,7 @@ data class YamlFluentCommand(
}

@SuppressWarnings("ComplexMethod")
private fun _toCommands(flowPath: Path, appId: String): List<MaestroCommand> {
private fun _toCommands(flowPath: Path, appId: String?): List<MaestroCommand> {
return when {
launchApp != null -> listOf(launchApp(launchApp, appId))
setPermissions != null -> listOf(setPermissions(command = setPermissions, appId))
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -508,7 +508,7 @@ data class YamlFluentCommand(
}

private fun runFlowCommand(
appId: String,
appId: String?,
flowPath: Path,
runFlow: YamlRunFlow
): MaestroCommand {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,51 @@ internal class YamlCommandReaderTest {
}


@Test
fun openLink_withoutConfigAppId(
@YamlFile("031_openLink_withoutConfigAppId.yaml") commands: List<Command>
) {
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<Command>
) {
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<SyntaxError> {
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<MaestroCommand> =
commands.map(::MaestroCommand).toList()

companion object {
private val PROJECT_DIR = System.getenv("PROJECT_DIR")
?: throw RuntimeException("Unable to determine project directory")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: No appId flow
---
- launchApp
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: No appId flow
---
- launchApp: com.other.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: No appId flow
---
- openLink: https://example.com
Original file line number Diff line number Diff line change
@@ -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 | │
╰────────────────────────────────────────────────────────────────────────╯
/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 | │
╰──────────────────────────────────────────────────────────────────────────────────╯
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
name: MyFlow
---
- launchApp
25 changes: 25 additions & 0 deletions maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: No appId flow
---
- launchApp: com.example.app
Loading