From 7fe934dcbff278017c630e0b4d7b5f891a961e99 Mon Sep 17 00:00:00 2001 From: Dmitry Zaytsev Date: Fri, 10 Jan 2025 20:03:45 +0100 Subject: [PATCH] Support Robin uploads for Web apps (#2238) --- .gitignore | 3 + .../java/maestro/cli/cloud/CloudInteractor.kt | 69 ++++++++++++++----- .../java/maestro/cli/command/CloudCommand.kt | 7 +- .../main/java/maestro/cli/util/FileUtils.kt | 6 ++ .../java/maestro/cli/web/WebInteractor.kt | 38 ++++++++++ 5 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt diff --git a/.gitignore b/.gitignore index c22074e917..2b4f6763a1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ bin # media assets maestro-orchestra/src/test/resources/media/assets/* + +# Local files +local/ \ No newline at end of file diff --git a/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt b/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt index c96b3eeeaf..48f5a49c1c 100644 --- a/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt +++ b/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt @@ -2,20 +2,21 @@ package maestro.cli.cloud import maestro.cli.CliError import maestro.cli.api.ApiClient -import maestro.cli.api.RobinUploadResponse -import maestro.cli.api.DeviceInfo -import maestro.cli.api.UploadStatus import maestro.cli.api.DeviceConfiguration +import maestro.cli.api.DeviceInfo import maestro.cli.api.MaestroCloudUploadResponse +import maestro.cli.api.RobinUploadResponse +import maestro.cli.api.UploadStatus import maestro.cli.auth.Auth import maestro.cli.device.Platform -import maestro.cli.model.RunningFlow import maestro.cli.model.FlowStatus +import maestro.cli.model.RunningFlow import maestro.cli.model.RunningFlows import maestro.cli.model.TestExecutionSummary import maestro.cli.report.ReportFormat import maestro.cli.report.ReporterFactory import maestro.cli.util.EnvUtils +import maestro.cli.util.FileUtils.isWebFlow import maestro.cli.util.FileUtils.isZip import maestro.cli.util.PrintUtils import maestro.cli.util.TimeUtils @@ -26,6 +27,7 @@ import maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel.Companion.toViewM import maestro.cli.view.TestSuiteStatusView.robinUploadUrl import maestro.cli.view.TestSuiteStatusView.uploadUrl import maestro.cli.view.box +import maestro.cli.web.WebInteractor import maestro.utils.TemporaryDirectory import okio.BufferedSink import okio.buffer @@ -33,7 +35,6 @@ import okio.sink import org.rauschig.jarchivelib.ArchiveFormat import org.rauschig.jarchivelib.ArchiverFactory import java.io.File -import java.util.* import java.util.concurrent.TimeUnit import kotlin.io.path.absolute @@ -72,7 +73,7 @@ class CloudInteractor( deviceLocale: String? = null, projectId: String? = null, ): Int { - if (appBinaryId == null && appFile == null) throw CliError("Missing required parameter for option '--app-file' or '--app-binary-id'") + if (appBinaryId == null && appFile == null && !flowFile.isWebFlow()) throw CliError("Missing required parameter for option '--app-file' or '--app-binary-id'") if (!flowFile.exists()) throw CliError("File does not exist: ${flowFile.absolutePath}") if (mapping?.exists() == false) throw CliError("File does not exist: ${mapping.absolutePath}") if (async && reportFormat != ReportFormat.NOOP) throw CliError("Cannot use --format with --async") @@ -104,6 +105,8 @@ class CloudInteractor( @Suppress("RemoveRedundantSpreadOperator") archiver.create(appFile.name + ".zip", tmpDir.toFile(), *arrayOf(appFile.absoluteFile)) } + } else if (flowFile.isWebFlow()) { + appFileToSend = WebInteractor.createManifestFromWorkspace(flowFile) } val response = client.upload( @@ -137,7 +140,8 @@ class CloudInteractor( val project = requireNotNull(projectId) val appId = response.appId val uploadUrl = robinUploadUrl(project, appId, response.uploadId, client.domain) - val deviceMessage = if (response.deviceConfiguration != null) printDeviceInfo(response.deviceConfiguration) else "" + val deviceMessage = + if (response.deviceConfiguration != null) printDeviceInfo(response.deviceConfiguration) else "" return printMaestroCloudResponse( async, authToken, @@ -153,6 +157,7 @@ class CloudInteractor( projectId, ) } + is MaestroCloudUploadResponse -> { println() val deviceInfo = response.deviceInfo @@ -161,12 +166,13 @@ class CloudInteractor( val uploadId = response.uploadId val appBinaryIdResponse = response.appBinaryId val uploadUrl = uploadUrl(uploadId, teamId, appId, client.domain) - val deviceInfoMessage = if (deviceInfo != null) printDeviceInfo(deviceInfo, iOSVersion, androidApiLevel) else "" + val deviceInfoMessage = + if (deviceInfo != null) printDeviceInfo(deviceInfo, iOSVersion, androidApiLevel) else "" return printMaestroCloudResponse( async = async, authToken = authToken, failOnCancellation = failOnCancellation, - reportFormat= reportFormat, + reportFormat = reportFormat, reportOutput = reportOutput, testSuiteName = testSuiteName, uploadUrl = uploadUrl, @@ -236,16 +242,19 @@ class CloudInteractor( val platform = Platform.fromString(deviceInfo.platform) val line1 = "Maestro Cloud device specs:\n* ${deviceInfo.displayInfo} - ${deviceInfo.deviceLocale}" - val line2 = "To change OS version use this option: ${if (platform == Platform.IOS) "--ios-version=" else "--android-api-level="}" + val line2 = + "To change OS version use this option: ${if (platform == Platform.IOS) "--ios-version=" else "--android-api-level="}" val line3 = "To change device locale use this option: --device-locale=" - val version = when(platform) { + val version = when (platform) { Platform.ANDROID -> "${androidApiLevel ?: 30}" // todo change with constant from DeviceConfigAndroid Platform.IOS -> "${iOSVersion ?: 15}" // todo change with constant from DeviceConfigIos else -> return "" } - val line4 = "To create a similar device locally, run: `maestro start-device --platform=${platform.toString().lowercase()} --os-version=$version --device-locale=${deviceInfo.deviceLocale}`" + val line4 = "To create a similar device locally, run: `maestro start-device --platform=${ + platform.toString().lowercase() + } --os-version=$version --device-locale=${deviceInfo.deviceLocale}`" return "$line1\n\n$line2\n\n$line3\n\n$line4".box() } @@ -253,12 +262,15 @@ class CloudInteractor( val platform = Platform.fromString(deviceConfiguration.platform) val line1 = "Robin device specs:\n* ${deviceConfiguration.displayInfo} - ${deviceConfiguration.deviceLocale}" - val line2 = "To change OS version use this option: ${if (platform == Platform.IOS) "--ios-version=" else "--android-api-level="}" + val line2 = + "To change OS version use this option: ${if (platform == Platform.IOS) "--ios-version=" else "--android-api-level="}" val line3 = "To change device locale use this option: --device-locale=" val version = deviceConfiguration.osVersion - val line4 = "To create a similar device locally, run: `maestro start-device --platform=${platform.toString().lowercase()} --os-version=$version --device-locale=${deviceConfiguration.deviceLocale}`" + val line4 = "To create a similar device locally, run: `maestro start-device --platform=${ + platform.toString().lowercase() + } --os-version=$version --device-locale=${deviceConfiguration.deviceLocale}`" return "$line1\n\n$line2\n\n$line3\n\n$line4".box() } @@ -310,15 +322,21 @@ class CloudInteractor( val runningFlow = runningFlows.flows.find { it.name == uploadFlowResult.name } ?: continue runningFlow.status = uploadFlowResult.status when (runningFlow.status) { - FlowStatus.PENDING -> { /* do nothing */ } + FlowStatus.PENDING -> { /* do nothing */ + } + FlowStatus.RUNNING -> { if (runningFlow.startTime == null) { runningFlow.startTime = System.currentTimeMillis() } } + else -> { if (runningFlow.duration == null) { - runningFlow.duration = TimeUtils.durationInSeconds(startTimeInMillis = runningFlow.startTime, endTimeInMillis = System.currentTimeMillis()) + runningFlow.duration = TimeUtils.durationInSeconds( + startTimeInMillis = runningFlow.startTime, + endTimeInMillis = System.currentTimeMillis() + ) } if (!runningFlow.reported) { TestSuiteStatusView.showFlowCompletion( @@ -331,7 +349,10 @@ class CloudInteractor( } if (upload.completed) { - runningFlows.duration = TimeUtils.durationInSeconds(startTimeInMillis = startTime, endTimeInMillis = System.currentTimeMillis()) + runningFlows.duration = TimeUtils.durationInSeconds( + startTimeInMillis = startTime, + endTimeInMillis = System.currentTimeMillis() + ) return handleSyncUploadCompletion( upload = upload, runningFlows = runningFlows, @@ -404,7 +425,13 @@ class CloudInteractor( } if (reportOutputSink != null) { - saveReport(reportFormat, !failed, createSuiteResult(!failed, upload, runningFlows), reportOutputSink, testSuiteName) + saveReport( + reportFormat, + !failed, + createSuiteResult(!failed, upload, runningFlows), + reportOutputSink, + testSuiteName + ) } @@ -440,7 +467,11 @@ class CloudInteractor( ) } - private fun createSuiteResult(passed: Boolean, upload: UploadStatus, runningFlows: RunningFlows): TestExecutionSummary.SuiteResult { + private fun createSuiteResult( + passed: Boolean, + upload: UploadStatus, + runningFlows: RunningFlows + ): TestExecutionSummary.SuiteResult { return TestExecutionSummary.SuiteResult( passed = passed, flows = upload.flows.map { uploadFlowResult -> diff --git a/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt b/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt index 62460c0ac0..97558b10a5 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt @@ -27,6 +27,7 @@ import maestro.cli.api.ApiClient import maestro.cli.cloud.CloudInteractor import maestro.cli.report.ReportFormat import maestro.cli.report.TestDebugReporter +import maestro.cli.util.FileUtils.isWebFlow import maestro.cli.util.PrintUtils import maestro.orchestra.util.Env.withInjectedShellEnvVars import maestro.orchestra.workspace.WorkspaceExecutionPlanner @@ -174,7 +175,7 @@ class CloudCommand : Callable { flattenDebugOutput = false, printToConsole = parent?.verbose == true, ) - + validateFiles() validateWorkSpace() @@ -258,8 +259,10 @@ class CloudCommand : Callable { } } - val hasApp = appFile != null || appBinaryId != null val hasWorkspace = this::flowsFile.isInitialized + val hasApp = appFile != null + || appBinaryId != null + || (this::flowsFile.isInitialized && this::flowsFile.get().isWebFlow()) if (!hasApp && !hasWorkspace) { throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption("--flows"), "Missing required parameters: '--app-file', " + diff --git a/maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt b/maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt index 9cbd7ca10a..6d00001489 100644 --- a/maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt +++ b/maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt @@ -16,6 +16,12 @@ object FileUtils { } fun File.isWebFlow(): Boolean { + if (isDirectory) { + return listFiles() + ?.any { it.isWebFlow() } + ?: false + } + val config = YamlCommandReader.readConfig(toPath()) return Regex("http(s?)://").containsMatchIn(config.appId) } diff --git a/maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt b/maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt new file mode 100644 index 0000000000..3efa0f2912 --- /dev/null +++ b/maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt @@ -0,0 +1,38 @@ +package maestro.cli.web + +import maestro.cli.util.FileUtils.isWebFlow +import maestro.orchestra.yaml.YamlCommandReader +import java.io.File + +object WebInteractor { + + fun createManifestFromWorkspace(workspaceFile: File): File? { + val appId = inferAppId(workspaceFile) ?: return null + + val manifest = """ + { + "url": "$appId" + } + """.trimIndent() + + val manifestFile = File.createTempFile("manifest", ".json") + manifestFile.writeText(manifest) + return manifestFile + } + + private fun inferAppId(file: File): String? { + if (file.isDirectory) { + return file.listFiles() + ?.firstNotNullOfOrNull { inferAppId(it) } + } + + if (!file.isWebFlow()) { + return null + } + + return file.readText() + .let { YamlCommandReader.readConfig(file.toPath()) } + .appId + } + +} \ No newline at end of file