From f1cf035db53bf3aecdea4aedfd398f321abe2e80 Mon Sep 17 00:00:00 2001 From: Amanjeet Singh Date: Thu, 29 Aug 2024 13:20:43 +0530 Subject: [PATCH] feat: enable maestro cloud uploads to new infra (#1970) --- .../main/java/maestro/cli/api/ApiClient.kt | 118 +++++++++--- .../java/maestro/cli/cloud/CloudInteractor.kt | 181 +++++++++++++----- .../java/maestro/cli/command/CloudCommand.kt | 50 +++-- .../maestro/cli/runner/TestSuiteInteractor.kt | 3 +- .../maestro/cli/view/TestSuiteStatusView.kt | 46 +++-- 5 files changed, 284 insertions(+), 114 deletions(-) diff --git a/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt b/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt index 0ec823fe72..16ed950c30 100644 --- a/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt +++ b/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt @@ -47,13 +47,15 @@ class ApiClient( .addInterceptor(SystemInformationInterceptor()) .build() - private val BASE_RETRY_DELAY_MS = 3000L - val domain: String get() { val regex = "https?://[^.]+.([a-zA-Z0-9.-]*).*".toRegex() val matchResult = regex.matchEntire(baseUrl) - val domain = matchResult?.groups?.get(1)?.value + val domain = if (!matchResult?.groups?.get(1)?.value.isNullOrEmpty()) { + matchResult?.groups?.get(1)?.value + } else { + matchResult?.groups?.get(0)?.value + } return domain ?: "mobile.dev" } @@ -166,10 +168,16 @@ class ApiClient( fun uploadStatus( authToken: String, uploadId: String, + projectId: String?, ): UploadStatus { + val baseUrl = if (projectId != null) { + "$baseUrl/upload/$uploadId" + } else { + "$baseUrl/v2/upload/$uploadId/status?includeErrors=true" + } val request = Request.Builder() .header("Authorization", "Bearer $authToken") - .url("$baseUrl/v2/upload/$uploadId/status?includeErrors=true") + .url(baseUrl) .get() .build() @@ -252,6 +260,7 @@ class ApiClient( disableNotifications: Boolean, deviceLocale: String? = null, progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> }, + projectId: String? = null, ): UploadResponse { if (appBinaryId == null && appFile == null) throw CliError("Missing required parameter for option '--app-file' or '--app-binary-id'") if (appFile != null && !appFile.exists()) throw CliError("App file does not exist: ${appFile.absolutePathString()}") @@ -272,6 +281,7 @@ class ApiClient( iOSVersion?.let { requestPart["iOSVersion"] = it } appBinaryId?.let { requestPart["appBinaryId"] = it } deviceLocale?.let { requestPart["deviceLocale"] = it } + projectId?.let { requestPart["projectId"] = it } if (includeTags.isNotEmpty()) requestPart["includeTags"] = includeTags if (excludeTags.isNotEmpty()) requestPart["excludeTags"] = excludeTags if (disableNotifications) requestPart["disableNotifications"] = true @@ -325,10 +335,15 @@ class ApiClient( ) } + val url = if (projectId != null) { + "$baseUrl/runMaestroTest" + } else { + "$baseUrl/v2/upload" + } val response = try { val request = Request.Builder() .header("Authorization", "Bearer $authToken") - .url("$baseUrl/v2/upload") + .url(url) .post(body) .build() @@ -350,25 +365,60 @@ class ApiClient( val responseBody = JSON.readValue(response.body?.bytes(), Map::class.java) - @Suppress("UNCHECKED_CAST") - val analysisRequest = responseBody["analysisRequest"] as Map - val uploadId = analysisRequest["id"] as String - val teamId = analysisRequest["teamId"] as String - val appId = responseBody["targetId"] as String - val appBinaryIdResponse = responseBody["appBinaryId"] as? String - val deviceInfoStr = responseBody["deviceInfo"] as? Map - - val deviceInfo = deviceInfoStr?.let { - DeviceInfo( - platform = it["platform"] as String, - displayInfo = it["displayInfo"] as String, - isDefaultOsVersion = it["isDefaultOsVersion"] as Boolean, - deviceLocale = responseBody["deviceLocale"] as String - ) + return if (projectId != null) { + parseRobinUploadResponse(responseBody) + } else { + parseMaestroCloudUpload(responseBody) } + } + } + + private fun parseRobinUploadResponse(responseBody: Map<*, *>): UploadResponse { + @Suppress("UNCHECKED_CAST") + val orgId = responseBody["orgId"] as String + val uploadId = responseBody["uploadId"] as String + val appId = responseBody["appId"] as String + val appBinaryId = responseBody["appBinaryId"] as String + + val deviceConfigMap = responseBody["deviceConfiguration"] as Map + val platform = deviceConfigMap["platform"].toString().uppercase() + val deviceConfiguration = DeviceConfiguration( + platform = platform, + deviceName = deviceConfigMap["deviceName"] as String, + orientation = deviceConfigMap["orientation"] as String, + osVersion = deviceConfigMap["osVersion"] as String, + displayInfo = deviceConfigMap["displayInfo"] as String, + deviceLocale = deviceConfigMap["deviceLocale"] as? String + ) + + return RobinUploadResponse( + orgId = orgId, + uploadId = uploadId, + deviceConfiguration = deviceConfiguration, + appId = appId, + appBinaryId = appBinaryId + ) + } - return UploadResponse(teamId, appId, uploadId, appBinaryIdResponse, deviceInfo) + private fun parseMaestroCloudUpload(responseBody: Map<*, *>): UploadResponse { + @Suppress("UNCHECKED_CAST") + val analysisRequest = responseBody["analysisRequest"] as Map + val uploadId = analysisRequest["id"] as String + val teamId = analysisRequest["teamId"] as String + val appId = responseBody["targetId"] as String + val appBinaryIdResponse = responseBody["appBinaryId"] as? String + val deviceInfoStr = responseBody["deviceInfo"] as? Map + + val deviceInfo = deviceInfoStr?.let { + DeviceInfo( + platform = it["platform"] as String, + displayInfo = it["displayInfo"] as String, + isDefaultOsVersion = it["isDefaultOsVersion"] as Boolean, + deviceLocale = responseBody["deviceLocale"] as String + ) } + + return MaestroCloudUploadResponse(teamId, appId, uploadId, appBinaryIdResponse, deviceInfo) } @@ -416,18 +466,38 @@ class ApiClient( ) : Exception("Request failed. Status code: $statusCode") companion object { - + private const val BASE_RETRY_DELAY_MS = 3000L private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } } +sealed class UploadResponse + @JsonIgnoreProperties(ignoreUnknown = true) -data class UploadResponse( +data class RobinUploadResponse( + val orgId: String, + val uploadId: String, + val appId: String, + val deviceConfiguration: DeviceConfiguration?, + val appBinaryId: String?, +): UploadResponse() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class MaestroCloudUploadResponse( val teamId: String, val appId: String, val uploadId: String, val appBinaryId: String?, val deviceInfo: DeviceInfo? +): UploadResponse() + +data class DeviceConfiguration( + val platform: String, + val deviceName: String, + val orientation: String, + val osVersion: String, + val displayInfo: String, + val deviceLocale: String? ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -440,7 +510,7 @@ data class DeviceInfo( @JsonIgnoreProperties(ignoreUnknown = true) data class UploadStatus( - val uploadId: UUID, + val uploadId: String, val status: Status, val completed: Boolean, val flows: List, 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 5e56957030..30e13287b8 100644 --- a/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt +++ b/maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt @@ -2,8 +2,11 @@ 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.MaestroCloudUploadResponse import maestro.cli.auth.Auth import maestro.cli.device.Platform import maestro.cli.model.RunningFlow @@ -66,6 +69,7 @@ class CloudInteractor( testSuiteName: String? = null, disableNotifications: Boolean = false, 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 (!flowFile.exists()) throw CliError("File does not exist: ${flowFile.absolutePath}") @@ -75,7 +79,7 @@ class CloudInteractor( val authToken = apiKey // Check for API key ?: auth.getCachedAuthToken() // Otherwise, if the user has already logged in, use the cached auth token ?: EnvUtils.maestroCloudApiKey() // Resolve API key from shell if set - ?: auth.triggerSignInFlow() // Otherwise, trigger the sign-in flow + ?: auth.triggerSignInFlow() // Otherwise, trigger the sign-in flow PrintUtils.message("Uploading Flow(s)...") @@ -101,7 +105,7 @@ class CloudInteractor( } } - val (teamId, appId, uploadId, appBinaryIdResponse, deviceInfo) = client.upload( + val response = client.upload( authToken = authToken, appFile = appFileToSend?.toPath(), workspaceZip = workspaceZip, @@ -120,57 +124,114 @@ class CloudInteractor( excludeTags = excludeTags, disableNotifications = disableNotifications, deviceLocale = deviceLocale, - ) { totalBytes, bytesWritten -> - progressBar.set(bytesWritten.toFloat() / totalBytes.toFloat()) - } + projectId = projectId, + progressListener = { totalBytes, bytesWritten -> + progressBar.set(bytesWritten.toFloat() / totalBytes.toFloat()) + } + ) - println() + when (response) { + is RobinUploadResponse -> { + println() + val project = requireNotNull(projectId) + val appId = response.appId + val uploadUrl = uploadUrl(project, appId, client.domain) + val deviceMessage = if (response.deviceConfiguration != null) printDeviceInfo(response.deviceConfiguration) else "" + val appBinaryIdResponseId = if (appBinaryId != null) response.appBinaryId else null + return printMaestroCloudResponse( + async, + authToken, + failOnCancellation, + reportFormat, + reportOutput, + testSuiteName, + uploadUrl, + deviceMessage, + appId, + appBinaryIdResponseId, + response.uploadId, + projectId, + ) + } + is MaestroCloudUploadResponse -> { + println() + val deviceInfo = response.deviceInfo + val teamId = response.teamId + val appId = response.appId + 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 "" + return printMaestroCloudResponse( + async = async, + authToken = authToken, + failOnCancellation = failOnCancellation, + reportFormat= reportFormat, + reportOutput = reportOutput, + testSuiteName = testSuiteName, + uploadUrl = uploadUrl, + deviceInfoMessage = deviceInfoMessage, + appId = appId, + appBinaryIdResponse = appBinaryIdResponse, + uploadId = uploadId + ) + } + } + } + } - if (async) { - PrintUtils.message("✅ Upload successful!") + private fun printMaestroCloudResponse( + async: Boolean, + authToken: String, + failOnCancellation: Boolean, + reportFormat: ReportFormat, + reportOutput: File?, + testSuiteName: String?, + uploadUrl: String, + deviceInfoMessage: String, + appId: String, + appBinaryIdResponse: String?, + uploadId: String, + projectId: String? = null + ): Int { + if (async) { + PrintUtils.message("✅ Upload successful!") - if (deviceInfo != null) printDeviceInfo(deviceInfo, iOSVersion, androidApiLevel) - PrintUtils.message("View the results of your upload below:") - PrintUtils.message(uploadUrl(uploadId, teamId, appId, client.domain)) + println(deviceInfoMessage) + PrintUtils.message("View the results of your upload below:") + PrintUtils.message(uploadUrl) - if (appBinaryIdResponse != null) PrintUtils.message("App binary id: $appBinaryIdResponse") + if (appBinaryIdResponse != null) PrintUtils.message("App binary id: $appBinaryIdResponse") - return 0 - } else { + return 0 + } else { - if (deviceInfo != null) printDeviceInfo(deviceInfo, iOSVersion, androidApiLevel) + println(deviceInfoMessage) - PrintUtils.message( - "Visit the web console for more details about the upload: ${ - uploadUrl( - uploadId, - teamId, - appId, - client.domain - ) - }" - ) + PrintUtils.message( + "Visit the web console for more details about the upload: $uploadUrl" + ) - if (appBinaryIdResponse != null) PrintUtils.message("App binary id: $appBinaryIdResponse") + if (appBinaryIdResponse != null) PrintUtils.message("App binary id: $appBinaryIdResponse") - PrintUtils.message("Waiting for analyses to complete...") - println() + PrintUtils.message("Waiting for analyses to complete...") + println() - return waitForCompletion( - authToken = authToken, - uploadId = uploadId, - teamId = teamId, - appId = appId, - failOnCancellation = failOnCancellation, - reportFormat = reportFormat, - reportOutput = reportOutput, - testSuiteName = testSuiteName, - ) - } + return waitForCompletion( + authToken = authToken, + uploadId = uploadId, + appId = appId, + failOnCancellation = failOnCancellation, + reportFormat = reportFormat, + reportOutput = reportOutput, + testSuiteName = testSuiteName, + uploadUrl = uploadUrl, + projectId = projectId, + ) } } - private fun printDeviceInfo(deviceInfo: DeviceInfo, iOSVersion: String?, androidApiLevel: Int?) { + private fun printDeviceInfo(deviceInfo: DeviceInfo, iOSVersion: String?, androidApiLevel: Int?): String { val platform = Platform.fromString(deviceInfo.platform) @@ -181,23 +242,37 @@ class CloudInteractor( 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 + 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}`" - PrintUtils.message("$line1\n\n$line2\n\n$line3\n\n$line4".box()) + return "$line1\n\n$line2\n\n$line3\n\n$line4".box() + } + + private fun printDeviceInfo(deviceConfiguration: DeviceConfiguration): String { + val platform = Platform.fromString(deviceConfiguration.platform) + + val line1 = "Maestro Cloud 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 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}`" + return "$line1\n\n$line2\n\n$line3\n\n$line4".box() } private fun waitForCompletion( authToken: String, uploadId: String, - teamId: String, appId: String, failOnCancellation: Boolean, reportFormat: ReportFormat, reportOutput: File?, - testSuiteName: String? + testSuiteName: String?, + uploadUrl: String, + projectId: String? ): Int { val startTime = System.currentTimeMillis() @@ -207,7 +282,7 @@ class CloudInteractor( var retryCounter = 0 do { val upload = try { - client.uploadStatus(authToken, uploadId) + client.uploadStatus(authToken, uploadId, projectId) } catch (e: ApiClient.ApiException) { if (e.statusCode == 429) { // back off through extending sleep duration with 25% @@ -227,7 +302,8 @@ class CloudInteractor( throw CliError("Failed to fetch the status of an upload $uploadId. Status code = ${e.statusCode}") } - for (uploadFlowResult in upload.flows) { + val flows = upload.flows + for (uploadFlowResult in flows) { if (runningFlows.flows.none { it.name == uploadFlowResult.name }) { runningFlows.flows.add(RunningFlow(name = uploadFlowResult.name)) } @@ -259,25 +335,24 @@ class CloudInteractor( return handleSyncUploadCompletion( upload = upload, runningFlows = runningFlows, - teamId = teamId, appId = appId, failOnCancellation = failOnCancellation, reportFormat = reportFormat, reportOutput = reportOutput, testSuiteName = testSuiteName, + uploadUrl = uploadUrl ) } Thread.sleep(pollingInterval) } while (System.currentTimeMillis() - startTime < waitTimeoutMs) - val consoleUrl = uploadUrl(uploadId, teamId, appId, client.domain) val displayedMin = TimeUnit.MILLISECONDS.toMinutes(waitTimeoutMs) PrintUtils.warn("Waiting for flows to complete has timed out ($displayedMin minutes)") PrintUtils.warn("* To extend the timeout, run maestro with this option `maestro cloud --timeout=`") - PrintUtils.warn("* Follow the results of your upload here:\n$consoleUrl") + PrintUtils.warn("* Follow the results of your upload here:\n$uploadUrl") return if (failOnTimeout) { @@ -296,22 +371,22 @@ class CloudInteractor( private fun handleSyncUploadCompletion( upload: UploadStatus, runningFlows: RunningFlows, - teamId: String, appId: String, failOnCancellation: Boolean, reportFormat: ReportFormat, reportOutput: File?, - testSuiteName: String? + testSuiteName: String?, + uploadUrl: String, ): Int { TestSuiteStatusView.showSuiteResult( upload.toViewModel( TestSuiteStatusView.TestSuiteViewModel.UploadDetails( uploadId = upload.uploadId, - teamId = teamId, appId = appId, domain = client.domain, ) - ) + ), + uploadUrl ) val isCancelled = upload.status == UploadStatus.Status.CANCELED 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 a48bf3d286..8b5e14ab3d 100644 --- a/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt +++ b/maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt @@ -65,41 +65,44 @@ class CloudCommand : Callable { @Option(order = 0, names = ["--api-key", "--apiKey"], description = ["API key"]) private var apiKey: String? = null - @Option(order = 1, names = ["--api-url", "--apiUrl"], description = ["API base URL"]) - private var apiUrl: String = "https://api.mobile.dev" + @Option(order = 1, names = ["--project-id", "--projectId"], description = ["Project Id"]) + private var projectId: String? = null - @Option(order = 2, names = ["--mapping"], description = ["dSYM file (iOS) or Proguard mapping file (Android)"]) + @Option(order = 2, names = ["--api-url", "--apiUrl"], description = ["API base URL"]) + private var apiUrl: String? = null + + @Option(order = 3, names = ["--mapping"], description = ["dSYM file (iOS) or Proguard mapping file (Android)"]) private var mapping: File? = null - @Option(order = 3, names = ["--repo-owner", "--repoOwner"], description = ["Repository owner (ie: GitHub organization or user slug)"]) + @Option(order = 4, names = ["--repo-owner", "--repoOwner"], description = ["Repository owner (ie: GitHub organization or user slug)"]) private var repoOwner: String? = null - @Option(order = 4, names = ["--repo-name", "--repoName"], description = ["Repository name (ie: GitHub repo slug)"]) + @Option(order = 5, names = ["--repo-name", "--repoName"], description = ["Repository name (ie: GitHub repo slug)"]) private var repoName: String? = null - @Option(order = 5, names = ["--branch"], description = ["The branch this upload originated from"]) + @Option(order = 6, names = ["--branch"], description = ["The branch this upload originated from"]) private var branch: String? = null - @Option(order = 6, names = ["--commit-sha", "--commitSha"], description = ["The commit SHA of this upload"]) + @Option(order = 7, names = ["--commit-sha", "--commitSha"], description = ["The commit SHA of this upload"]) private var commitSha: String? = null - @Option(order = 7, names = ["--pull-request-id", "--pullRequestId"], description = ["The ID of the pull request this upload originated from"]) + @Option(order = 8, names = ["--pull-request-id", "--pullRequestId"], description = ["The ID of the pull request this upload originated from"]) private var pullRequestId: String? = null - @Option(order = 8, names = ["-e", "--env"], description = ["Environment variables to inject into your Flows"]) + @Option(order = 9, names = ["-e", "--env"], description = ["Environment variables to inject into your Flows"]) private var env: Map = emptyMap() - @Option(order = 9, names = ["--name"], description = ["Name of the upload"]) + @Option(order = 10, names = ["--name"], description = ["Name of the upload"]) private var uploadName: String? = null - @Option(order = 10, names = ["--async"], description = ["Run the upload asynchronously"]) + @Option(order = 11, names = ["--async"], description = ["Run the upload asynchronously"]) private var async: Boolean = false - @Option(order = 11, names = ["--android-api-level"], description = ["Android API level to run your flow against"]) + @Option(order = 12, names = ["--android-api-level"], description = ["Android API level to run your flow against"]) private var androidApiLevel: Int? = null @Option( - order = 12, + order = 13, names = ["--include-tags"], description = ["List of tags that will remove the Flows that does not have the provided tags"], split = ",", @@ -107,7 +110,7 @@ class CloudCommand : Callable { private var includeTags: List = emptyList() @Option( - order = 13, + order = 14, names = ["--exclude-tags"], description = ["List of tags that will remove the Flows containing the provided tags"], split = ",", @@ -115,7 +118,7 @@ class CloudCommand : Callable { private var excludeTags: List = emptyList() @Option( - order = 14, + order = 15, names = ["--format"], description = ["Test report format (default=\${DEFAULT-VALUE}): \${COMPLETION-CANDIDATES}"], ) @@ -128,19 +131,19 @@ class CloudCommand : Callable { private var testSuiteName: String? = null @Option( - order = 15, + order = 16, names = ["--output"], description = ["File to write report into (default=report.xml)"], ) private var output: File? = null - @Option(order = 16, names = ["--ios-version"], description = ["iOS version to run your flow against"]) + @Option(order = 17, names = ["--ios-version"], description = ["iOS version to run your flow against"]) private var iOSVersion: String? = null - @Option(order = 17, names = ["--app-binary-id", "--appBinaryId"], description = ["The ID of the app binary previously uploaded to Maestro Cloud"]) + @Option(order = 18, names = ["--app-binary-id", "--appBinaryId"], description = ["The ID of the app binary previously uploaded to Maestro Cloud"]) private var appBinaryId: String? = null - @Option(order = 18, names = ["--device-locale"], description = ["Locale that will be set to a device, ISO-639-1 code and uppercase ISO-3166-1 code i.e. \"de_DE\" for Germany"]) + @Option(order = 19, names = ["--device-locale"], description = ["Locale that will be set to a device, ISO-639-1 code and uppercase ISO-3166-1 code i.e. \"de_DE\" for Germany"]) private var deviceLocale: String? = null @Option(hidden = true, names = ["--fail-on-cancellation"], description = ["Fail the command if the upload is marked as cancelled"]) @@ -161,6 +164,14 @@ class CloudCommand : Callable { validateWorkSpace() // Upload + val apiUrl = apiUrl ?: run { + if (projectId != null) { + "https://api.copilot.mobile.dev/v2/project/$projectId" + } else { + "https://api.mobile.dev" + } + } + return CloudInteractor( client = ApiClient(apiUrl), failOnTimeout = failOnTimeout, @@ -189,6 +200,7 @@ class CloudCommand : Callable { testSuiteName = testSuiteName, disableNotifications = disableNotifications, deviceLocale = deviceLocale, + projectId = projectId, ) } diff --git a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt index e90564c40f..671941c4b7 100644 --- a/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt +++ b/maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt @@ -91,7 +91,8 @@ class TestSuiteInteractor( duration = it.duration, ) }, - ) + ), + uploadUrl = "" ) val summary = TestExecutionSummary( diff --git a/maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt b/maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt index 9a7b2792c6..2f8ead4217 100644 --- a/maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt +++ b/maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt @@ -4,6 +4,7 @@ import maestro.cli.api.UploadStatus import maestro.cli.model.FlowStatus import maestro.cli.util.PrintUtils import maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel.FlowResult +import maestro.cli.view.TestSuiteStatusView.uploadUrl import org.fusesource.jansi.Ansi import java.util.UUID import kotlin.time.Duration @@ -38,6 +39,7 @@ object TestSuiteStatusView { fun showSuiteResult( suite: TestSuiteViewModel, + uploadUrl: String, ) { val hasError = suite.flows.find { it.status == FlowStatus.ERROR } != null val canceledFlows = suite.flows @@ -80,14 +82,7 @@ object TestSuiteStatusView { if (suite.uploadDetails != null) { println("==== View details in the console ====") - PrintUtils.message( - uploadUrl( - suite.uploadDetails.uploadId.toString(), - suite.uploadDetails.teamId, - suite.uploadDetails.appId, - suite.uploadDetails.domain, - ) - ) + PrintUtils.message(uploadUrl) println() } } @@ -130,6 +125,18 @@ object TestSuiteStatusView { domain: String = "mobile.dev", ) = "https://console.$domain/uploads/$uploadId?teamId=$teamId&appId=$appId" + fun uploadUrl( + projectId: String, + appId: String, + domain: String = "" + ): String { + return if (domain.contains("localhost")) { + "http://localhost:3000/project/$projectId/maestro-tests/app/$appId" + } else { + "https://copilot.mobile.dev/project/$projectId/maestro-tests/app/$appId" + } + } + private fun flowWord(count: Int) = if (count == 1) "Flow" else "Flows" data class TestSuiteViewModel( @@ -148,8 +155,7 @@ object TestSuiteStatusView { ) data class UploadDetails( - val uploadId: UUID, - val teamId: String, + val uploadId: String, val appId: String, val domain: String, ) @@ -184,13 +190,13 @@ object TestSuiteStatusView { // Helped launcher to play around with presentation fun main() { + val uploadDetails = TestSuiteStatusView.TestSuiteViewModel.UploadDetails( + uploadId = UUID.randomUUID().toString(), + appId = "appid", + domain = "mobile.dev", + ) val status = TestSuiteStatusView.TestSuiteViewModel( - uploadDetails = TestSuiteStatusView.TestSuiteViewModel.UploadDetails( - uploadId = UUID.randomUUID(), - teamId = "teamid", - appId = "appid", - domain = "mobile.dev", - ), + uploadDetails = uploadDetails, status = FlowStatus.CANCELED, flows = listOf( FlowResult( @@ -216,5 +222,11 @@ fun main() { TestSuiteStatusView.showFlowCompletion(it) } - TestSuiteStatusView.showSuiteResult(status) + val uploadUrl = uploadUrl( + uploadDetails.uploadId.toString(), + "teamid", + uploadDetails.appId, + uploadDetails.domain, + ) + TestSuiteStatusView.showSuiteResult(status, uploadUrl) }