Repository: mobile-dev-inc/Maestro Branch: main Commit: f24a6f8fdcca Files: 1166 Total size: 13.8 MB Directory structure: gitextract_0f8ml73h/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ └── feature_request.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── scripts/ │ │ └── boot_simulator.sh │ └── workflows/ │ ├── close-inactive-issues.yaml │ ├── lock-closed-issues.yaml │ ├── publish-cli.yaml │ ├── publish-release.yaml │ ├── publish-snapshot.yaml │ ├── test-e2e-ios-intel.yaml │ ├── test-e2e-prod.yaml │ ├── test-e2e.yaml │ ├── test.yaml │ └── update-samples.yaml ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── .name │ └── dictionaries/ │ └── project.xml ├── .run/ │ ├── cli-version.run.xml │ └── cli.run.xml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASING.md ├── build.gradle.kts ├── debug.keystore ├── detekt.yml ├── e2e/ │ ├── .gitignore │ ├── README.md │ ├── download_apps │ ├── install_apps │ ├── manifest.txt │ ├── run_tests │ ├── update_samples │ └── workspaces/ │ ├── setOrientation/ │ │ └── test-set-orientation-flow.yaml │ ├── simple_web_view/ │ │ └── webview.yaml │ └── wikipedia/ │ ├── android-advanced-flow.yaml │ ├── android-flow.yaml │ ├── ios-advanced-flow.yaml │ ├── ios-flow.yaml │ ├── scripts/ │ │ └── getSearchQuery.js │ ├── subflows/ │ │ ├── launch-clearstate-android.yaml │ │ ├── launch-clearstate-ios.yaml │ │ ├── onboarding-android.yaml │ │ └── onboarding-ios.yaml │ └── wikipedia-android-advanced/ │ ├── auth/ │ │ ├── login.yml │ │ └── signup.yml │ ├── dashboard/ │ │ ├── copy-paste.yml │ │ ├── feed.yml │ │ ├── main.yml │ │ ├── saved.yml │ │ └── search.yml │ ├── onboarding/ │ │ ├── add-language.yml │ │ ├── main.yml │ │ └── remove-language.yml │ ├── run-test.yml │ └── scripts/ │ ├── fetchTestUser.js │ └── generateCredentials.js ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── installLocally.sh ├── maestro ├── maestro-ai/ │ ├── README.md │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ ├── java/ │ │ └── maestro/ │ │ └── ai/ │ │ ├── AI.kt │ │ ├── CloudPredictionAIEngine.kt │ │ ├── DemoApp.kt │ │ ├── IAPredictionEngine.kt │ │ ├── Prediction.kt │ │ ├── anthropic/ │ │ │ ├── Client.kt │ │ │ ├── Common.kt │ │ │ ├── Request.kt │ │ │ └── Response.kt │ │ ├── cloud/ │ │ │ └── ApiClient.kt │ │ ├── common/ │ │ │ └── Image.kt │ │ └── openai/ │ │ ├── Client.kt │ │ ├── Request.kt │ │ └── Response.kt │ └── resources/ │ ├── askForDefects_schema.json │ └── extractText_schema.json ├── maestro-android/ │ ├── build.gradle.kts │ └── src/ │ ├── androidTest/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ ├── androidx/ │ │ │ └── test/ │ │ │ └── uiautomator/ │ │ │ └── UiDeviceExt.kt │ │ └── dev/ │ │ └── mobile/ │ │ └── maestro/ │ │ ├── AccessibilityNodeInfoExt.kt │ │ ├── MaestroDriverService.kt │ │ ├── Media.kt │ │ ├── ToastAccessibilityListener.kt │ │ ├── ViewHierarchy.kt │ │ ├── location/ │ │ │ ├── FusedLocationProvider.kt │ │ │ ├── LocationManagerProvider.kt │ │ │ ├── MockLocationProvider.kt │ │ │ └── PlayServices.kt │ │ └── screenshot/ │ │ ├── ScreenshotService.kt │ │ └── ScreenshotServiceTest.kt │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── dev/ │ │ └── mobile/ │ │ └── maestro/ │ │ ├── handlers/ │ │ │ ├── AbstractSettingHandler.kt │ │ │ └── LocaleSettingHandler.kt │ │ └── receivers/ │ │ ├── HasAction.kt │ │ └── LocaleSettingReceiver.kt │ └── res/ │ └── values/ │ └── stub.xml ├── maestro-cli/ │ ├── build.gradle.kts │ ├── gradle.properties │ ├── jvm-version.jar │ └── src/ │ ├── jreleaser/ │ │ └── distributions/ │ │ └── maestro/ │ │ └── brew/ │ │ └── formula.rb.tpl │ ├── main/ │ │ ├── java/ │ │ │ └── maestro/ │ │ │ └── cli/ │ │ │ ├── App.kt │ │ │ ├── CliError.kt │ │ │ ├── Dependencies.kt │ │ │ ├── DisableAnsiMixin.kt │ │ │ ├── ShowHelpMixin.kt │ │ │ ├── analytics/ │ │ │ │ ├── Analytics.kt │ │ │ │ ├── AnalyticsStateManager.kt │ │ │ │ └── PostHogEvents.kt │ │ │ ├── api/ │ │ │ │ ├── ApiClient.kt │ │ │ │ └── Chatbot.kt │ │ │ ├── auth/ │ │ │ │ └── Auth.kt │ │ │ ├── cloud/ │ │ │ │ └── CloudInteractor.kt │ │ │ ├── command/ │ │ │ │ ├── BugReportCommand.kt │ │ │ │ ├── ChatCommand.kt │ │ │ │ ├── CheckSyntaxCommand.kt │ │ │ │ ├── CloudCommand.kt │ │ │ │ ├── DownloadSamplesCommand.kt │ │ │ │ ├── DriverCommand.kt │ │ │ │ ├── ListCloudDevicesCommand.kt │ │ │ │ ├── ListDevicesCommand.kt │ │ │ │ ├── LoginCommand.kt │ │ │ │ ├── LogoutCommand.kt │ │ │ │ ├── McpCommand.kt │ │ │ │ ├── PrintHierarchyCommand.kt │ │ │ │ ├── QueryCommand.kt │ │ │ │ ├── RecordCommand.kt │ │ │ │ ├── StartDeviceCommand.kt │ │ │ │ ├── StudioCommand.kt │ │ │ │ └── TestCommand.kt │ │ │ ├── db/ │ │ │ │ └── KeyValueStore.kt │ │ │ ├── device/ │ │ │ │ ├── DeviceCreateUtil.kt │ │ │ │ ├── PickDeviceInteractor.kt │ │ │ │ └── PickDeviceView.kt │ │ │ ├── driver/ │ │ │ │ ├── DriverBuildConfig.kt │ │ │ │ ├── DriverBuilder.kt │ │ │ │ ├── RealIOSDeviceDriver.kt │ │ │ │ ├── Spinner.kt │ │ │ │ └── XcodeBuildProcessBuilderFactory.kt │ │ │ ├── graphics/ │ │ │ │ ├── AWTUtils.kt │ │ │ │ ├── LocalVideoRenderer.kt │ │ │ │ ├── RemoteVideoRenderer.kt │ │ │ │ ├── SkiaFrameRenderer.kt │ │ │ │ ├── SkiaTextClipper.kt │ │ │ │ ├── SkiaUtils.kt │ │ │ │ └── VideoRenderer.kt │ │ │ ├── insights/ │ │ │ │ └── TestAnalysisManager.kt │ │ │ ├── mcp/ │ │ │ │ ├── McpServer.kt │ │ │ │ ├── README.md │ │ │ │ └── tools/ │ │ │ │ ├── BackTool.kt │ │ │ │ ├── CheatSheetTool.kt │ │ │ │ ├── CheckFlowSyntaxTool.kt │ │ │ │ ├── InputTextTool.kt │ │ │ │ ├── InspectViewHierarchyTool.kt │ │ │ │ ├── LaunchAppTool.kt │ │ │ │ ├── ListDevicesTool.kt │ │ │ │ ├── QueryDocsTool.kt │ │ │ │ ├── RunFlowFilesTool.kt │ │ │ │ ├── RunFlowTool.kt │ │ │ │ ├── StartDeviceTool.kt │ │ │ │ ├── StopAppTool.kt │ │ │ │ ├── TakeScreenshotTool.kt │ │ │ │ ├── TapOnTool.kt │ │ │ │ └── ViewHierarchyFormatters.kt │ │ │ ├── model/ │ │ │ │ ├── FlowStatus.kt │ │ │ │ ├── RunningFlow.kt │ │ │ │ └── TestExecutionSummary.kt │ │ │ ├── promotion/ │ │ │ │ └── PromotionStateManager.kt │ │ │ ├── report/ │ │ │ │ ├── HtmlAITestSuiteReporter.kt │ │ │ │ ├── HtmlInsightsAnalysisReporter.kt │ │ │ │ ├── HtmlTestSuiteReporter.kt │ │ │ │ ├── JUnitTestSuiteReporter.kt │ │ │ │ ├── ReportFormat.kt │ │ │ │ ├── ReporterFactory.kt │ │ │ │ ├── TestDebugReporter.kt │ │ │ │ └── TestSuiteReporter.kt │ │ │ ├── runner/ │ │ │ │ ├── CliWatcher.kt │ │ │ │ ├── CommandState.kt │ │ │ │ ├── CommandStatus.kt │ │ │ │ ├── FileWatcher.kt │ │ │ │ ├── MaestroCommandRunner.kt │ │ │ │ ├── TestRunner.kt │ │ │ │ ├── TestSuiteInteractor.kt │ │ │ │ └── resultview/ │ │ │ │ ├── AnsiResultView.kt │ │ │ │ ├── PlainTextResultView.kt │ │ │ │ ├── ResultView.kt │ │ │ │ └── UiState.kt │ │ │ ├── session/ │ │ │ │ ├── MaestroSessionManager.kt │ │ │ │ └── SessionStore.kt │ │ │ ├── update/ │ │ │ │ └── Updates.kt │ │ │ ├── util/ │ │ │ │ ├── ChangeLogUtils.kt │ │ │ │ ├── CiUtils.kt │ │ │ │ ├── DependencyResolver.kt │ │ │ │ ├── EnvUtils.kt │ │ │ │ ├── ErrorReporter.kt │ │ │ │ ├── FileDownloader.kt │ │ │ │ ├── FileUtils.kt │ │ │ │ ├── IOSEnvUtils.kt │ │ │ │ ├── PrintUtils.kt │ │ │ │ ├── ResourceUtils.kt │ │ │ │ ├── ScreenReporter.kt │ │ │ │ ├── ScreenshotUtils.kt │ │ │ │ ├── SocketUtils.kt │ │ │ │ ├── TimeUtils.kt │ │ │ │ ├── Unpacker.kt │ │ │ │ ├── WorkingDirectory.kt │ │ │ │ └── WorkspaceUtils.kt │ │ │ ├── view/ │ │ │ │ ├── ErrorViewUtils.kt │ │ │ │ ├── ProgressBar.kt │ │ │ │ ├── TestSuiteStatusView.kt │ │ │ │ └── ViewUtils.kt │ │ │ └── web/ │ │ │ └── WebInteractor.kt │ │ └── resources/ │ │ ├── ai_report.css │ │ ├── deps/ │ │ │ └── applesimutils │ │ ├── html-detailed.css │ │ ├── logback-test.xml │ │ └── tailwind.config.js │ └── test/ │ ├── kotlin/ │ │ └── maestro/ │ │ └── cli/ │ │ ├── android/ │ │ │ └── AndroidDeviceProvider.kt │ │ ├── cloud/ │ │ │ └── CloudInteractorTest.kt │ │ ├── command/ │ │ │ └── TestCommandTest.kt │ │ ├── driver/ │ │ │ ├── DriverBuilderTest.kt │ │ │ └── RealDeviceDriverTest.kt │ │ ├── report/ │ │ │ ├── HtmlTestSuiteReporterTest.kt │ │ │ ├── JUnitTestSuiteReporterTest.kt │ │ │ ├── TestDebugReporterTest.kt │ │ │ └── TestSuiteReporterTest.kt │ │ ├── runner/ │ │ │ └── resultview/ │ │ │ └── PlainTextResultViewTest.kt │ │ └── util/ │ │ ├── ChangeLogUtilsTest.kt │ │ ├── DependencyResolverTest.kt │ │ └── WorkspaceUtilsTest.kt │ ├── mcp/ │ │ ├── README.md │ │ ├── full-evals.yaml │ │ ├── inspect-view-hierarchy-evals.yaml │ │ ├── launch_app_with_env_replacement.yaml │ │ ├── maestro-mcp.json │ │ ├── mcp-server-config.json │ │ ├── run_mcp_evals.sh │ │ ├── run_mcp_tool_tests.sh │ │ ├── setup/ │ │ │ ├── check-maestro-cli-built.sh │ │ │ ├── download-and-install-apps.sh │ │ │ ├── flows/ │ │ │ │ ├── launch-demo-app-ios.yaml │ │ │ │ ├── launch-safari-ios.yaml │ │ │ │ ├── setup-wikipedia-search-android.yaml │ │ │ │ ├── setup-wikipedia-search-ios.yaml │ │ │ │ └── verify-ready-state.yaml │ │ │ ├── launch-simulator.sh │ │ │ └── setup_and_run_eval.sh │ │ ├── tool-tests-with-device.yaml │ │ └── tool-tests-without-device.yaml │ └── resources/ │ ├── apps/ │ │ └── web-manifest.json │ ├── location/ │ │ └── assert_multiple_locations.yaml │ ├── travel/ │ │ └── assert_travel_command.yaml │ └── workspaces/ │ ├── cloud_test/ │ │ ├── android/ │ │ │ └── flow.yaml │ │ ├── ios/ │ │ │ └── flow.yaml │ │ ├── tagged/ │ │ │ ├── regression.yaml │ │ │ └── smoke.yaml │ │ └── web/ │ │ └── flow.yaml │ └── test_command_test/ │ ├── 00_mixed_web_mobile_flow_tests/ │ │ ├── mobileflow.yaml │ │ ├── mobileflow2.yaml │ │ ├── webflow.yaml │ │ └── webflow2.yaml │ ├── 01_web_only/ │ │ ├── webflow.yaml │ │ └── webflow2.yaml │ ├── 02_mobile_only/ │ │ ├── mobileflow1.yaml │ │ └── mobileflow2.yaml │ ├── 03_mixed_with_config_execution_order/ │ │ ├── config.yaml │ │ └── subFolder/ │ │ ├── mobileflow.yaml │ │ ├── mobileflow2.yaml │ │ ├── webflow.yaml │ │ └── webflow2.yaml │ └── 04_web_only_with_config_execution_order/ │ ├── config.yaml │ └── subFolder/ │ ├── mobileflow.yaml │ ├── mobileflow2.yaml │ ├── webflow.yaml │ └── webflow2.yaml ├── maestro-client/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── maestro/ │ │ │ ├── Bounds.kt │ │ │ ├── Capability.kt │ │ │ ├── DeviceInfo.kt │ │ │ ├── Driver.kt │ │ │ ├── Errors.kt │ │ │ ├── Filters.kt │ │ │ ├── FindElementResult.kt │ │ │ ├── KeyCode.kt │ │ │ ├── Maestro.kt │ │ │ ├── Media.kt │ │ │ ├── OnDeviceElementQuery.kt │ │ │ ├── OnDeviceElementQueryResult.kt │ │ │ ├── Point.kt │ │ │ ├── ScreenRecording.kt │ │ │ ├── ScrollDirection.kt │ │ │ ├── SwipeDirection.kt │ │ │ ├── TapRepeat.kt │ │ │ ├── TreeNode.kt │ │ │ ├── UiElement.kt │ │ │ ├── ViewHierarchy.kt │ │ │ ├── android/ │ │ │ │ ├── AndroidAppFiles.kt │ │ │ │ ├── AndroidBuildToolsDirectory.kt │ │ │ │ ├── AndroidLaunchArguments.kt │ │ │ │ └── chromedevtools/ │ │ │ │ ├── AndroidWebViewHierarchyClient.kt │ │ │ │ ├── DadbChromeDevToolsClient.kt │ │ │ │ └── DadbSocket.kt │ │ │ ├── auth/ │ │ │ │ └── ApiKey.kt │ │ │ ├── debuglog/ │ │ │ │ ├── DebugLogStore.kt │ │ │ │ └── LogConfig.kt │ │ │ ├── device/ │ │ │ │ ├── Device.kt │ │ │ │ ├── DeviceError.kt │ │ │ │ ├── DeviceOrientation.kt │ │ │ │ ├── DeviceService.kt │ │ │ │ ├── DeviceSpec.kt │ │ │ │ ├── Platform.kt │ │ │ │ ├── locale/ │ │ │ │ │ ├── AndroidLocale.kt │ │ │ │ │ ├── DeviceLocale.kt │ │ │ │ │ ├── IosLocale.kt │ │ │ │ │ ├── LocaleValidationException.kt │ │ │ │ │ └── WebLocale.kt │ │ │ │ ├── serialization/ │ │ │ │ │ ├── DeviceLocaleSerializer.kt │ │ │ │ │ └── DeviceSpecModule.kt │ │ │ │ └── util/ │ │ │ │ ├── AndroidEnvUtils.kt │ │ │ │ ├── AvdDevice.kt │ │ │ │ ├── CommandLineUtils.kt │ │ │ │ ├── EnvUtils.kt │ │ │ │ ├── PrintUtils.kt │ │ │ │ ├── SimctlList.kt │ │ │ │ └── SystemInfo.kt │ │ │ ├── drivers/ │ │ │ │ ├── AndroidDriver.kt │ │ │ │ ├── CdpWebDriver.kt │ │ │ │ ├── IOSDriver.kt │ │ │ │ └── WebDriver.kt │ │ │ ├── js/ │ │ │ │ ├── GraalJsEngine.kt │ │ │ │ ├── GraalJsHttp.kt │ │ │ │ ├── Js.kt │ │ │ │ ├── JsConsole.kt │ │ │ │ ├── JsEngine.kt │ │ │ │ ├── JsHttp.kt │ │ │ │ ├── JsScope.kt │ │ │ │ └── RhinoJsEngine.kt │ │ │ ├── mockserver/ │ │ │ │ └── MockInteractor.kt │ │ │ └── utils/ │ │ │ ├── BlockingStreamObserver.kt │ │ │ ├── FileUtils.kt │ │ │ ├── HttpUtils.kt │ │ │ ├── LocaleUtils.kt │ │ │ ├── ScreenshotUtils.kt │ │ │ ├── StringUtils.kt │ │ │ └── TemporaryDirectory.kt │ │ └── resources/ │ │ ├── maestro-app.apk │ │ ├── maestro-server.apk │ │ └── maestro-web.js │ └── test/ │ ├── java/ │ │ └── maestro/ │ │ ├── FiltersTest.kt │ │ ├── PointTest.kt │ │ ├── UiElementTest.kt │ │ ├── android/ │ │ │ ├── AndroidAppFilesTest.kt │ │ │ ├── AndroidLaunchArgumentsTest.kt │ │ │ └── chromedevtools/ │ │ │ └── AndroidWebViewHierarchyClientTest.kt │ │ ├── device/ │ │ │ ├── DeviceServiceTest.kt │ │ │ ├── DeviceSpecTest.kt │ │ │ └── serialization/ │ │ │ └── DeviceSpecSerializationTest.kt │ │ ├── ios/ │ │ │ └── MockXCTestInstaller.kt │ │ ├── locale/ │ │ │ └── DeviceLocaleTest.kt │ │ ├── utils/ │ │ │ ├── HttpUtilsTest.kt │ │ │ └── StringUtilsTest.kt │ │ └── xctestdriver/ │ │ └── XCTestDriverClientTest.kt │ └── resources/ │ └── logback-test.xml ├── maestro-ios/ │ ├── README.md │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ └── java/ │ └── ios/ │ ├── IOSDeviceErrors.kt │ ├── LocalIOSDevice.kt │ ├── devicectl/ │ │ └── DeviceControlIOSDevice.kt │ └── xctest/ │ └── XCTestIOSDevice.kt ├── maestro-ios-driver/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ ├── device/ │ │ │ │ ├── IOSDevice.kt │ │ │ │ └── SimctlIOSDevice.kt │ │ │ ├── hierarchy/ │ │ │ │ └── AXElement.kt │ │ │ ├── util/ │ │ │ │ ├── CommandLineUtils.kt │ │ │ │ ├── IOSDevice.kt │ │ │ │ ├── IOSLaunchArguments.kt │ │ │ │ ├── LocalIOSDevice.kt │ │ │ │ ├── LocalIOSDeviceController.kt │ │ │ │ ├── LocalSimulatorUtils.kt │ │ │ │ ├── PrintUtils.kt │ │ │ │ ├── SimctlList.kt │ │ │ │ └── XCRunnerCLIUtils.kt │ │ │ └── xcuitest/ │ │ │ ├── XCTestClient.kt │ │ │ ├── XCTestDriverClient.kt │ │ │ ├── api/ │ │ │ │ ├── DeviceInfo.kt │ │ │ │ ├── EraseTextRequest.kt │ │ │ │ ├── Error.kt │ │ │ │ ├── GetRunningAppIdResponse.kt │ │ │ │ ├── GetRunningAppRequest.kt │ │ │ │ ├── InputTextRequest.kt │ │ │ │ ├── IsScreenStaticResponse.kt │ │ │ │ ├── KeyboardInfoRequest.kt │ │ │ │ ├── KeyboardInfoResponse.kt │ │ │ │ ├── LaunchAppRequest.kt │ │ │ │ ├── NetworkExceptions.kt │ │ │ │ ├── OkHttpClientInstance.kt │ │ │ │ ├── PressButtonRequest.kt │ │ │ │ ├── PressKeyRequest.kt │ │ │ │ ├── SetOrientationRequest.kt │ │ │ │ ├── SetPermissionsRequest.kt │ │ │ │ ├── SwipeRequest.kt │ │ │ │ ├── TerminateAppRequest.kt │ │ │ │ ├── TouchRequest.kt │ │ │ │ └── ViewHierarchyRequest.kt │ │ │ └── installer/ │ │ │ ├── IOSBuildProductsExtractor.kt │ │ │ ├── LocalXCTestInstaller.kt │ │ │ └── XCTestInstaller.kt │ │ └── resources/ │ │ ├── driver-iPhoneSimulator/ │ │ │ └── maestro-driver-ios-config.xctestrun │ │ ├── driver-iphoneos/ │ │ │ └── maestro-driver-ios-config.xctestrun │ │ └── screenrecord.sh │ └── test/ │ └── kotlin/ │ ├── DeviceCtlResponseTest.kt │ ├── IOSBuildProductsExtractorTest.kt │ └── IOSLaunchArgumentsTest.kt ├── maestro-ios-xctest-runner/ │ ├── .gitignore │ ├── MaestroDriverLib/ │ │ ├── Info.plist │ │ ├── Package.swift │ │ ├── Sources/ │ │ │ └── MaestroDriverLib/ │ │ │ ├── Helpers/ │ │ │ │ └── PermissionButtonFinder.swift │ │ │ ├── MaestroDriverLib.swift │ │ │ └── Models/ │ │ │ ├── AXElement.swift │ │ │ ├── AXFrame.swift │ │ │ ├── ElementType.swift │ │ │ └── PermissionValue.swift │ │ └── Tests/ │ │ └── MaestroDriverLibTests/ │ │ ├── AXElementTests.swift │ │ ├── AXFrameTests.swift │ │ └── PermissionButtonFinderTests.swift │ ├── build-maestro-ios-runner-all.sh │ ├── build-maestro-ios-runner.sh │ ├── maestro-driver-ios/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Base.lproj/ │ │ │ ├── LaunchScreen.storyboard │ │ │ └── Main.storyboard │ │ ├── Info.plist │ │ ├── SceneDelegate.swift │ │ └── ViewController.swift │ ├── maestro-driver-ios.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm/ │ │ │ └── Package.resolved │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ ├── maestro-driver-ios.xcscheme │ │ └── maestro-driver-iosTests.xcscheme │ ├── maestro-driver-iosTests/ │ │ ├── Info.plist │ │ ├── SnapshotParametersTests.swift │ │ └── maestro-driver-iosTests-Bridging-Header.h │ ├── maestro-driver-iosUITests/ │ │ ├── Categories/ │ │ │ ├── XCAXClient_iOS+FBSnapshotReqParams.h │ │ │ ├── XCAXClient_iOS+FBSnapshotReqParams.m │ │ │ ├── XCUIApplication+FBQuiescence.h │ │ │ ├── XCUIApplication+FBQuiescence.m │ │ │ ├── XCUIApplication+Helper.h │ │ │ ├── XCUIApplication+Helper.m │ │ │ ├── XCUIApplicationProcess+FBQuiescence.h │ │ │ ├── XCUIApplicationProcess+FBQuiescence.m │ │ │ └── maestro-driver-iosUITests-Bridging-Header.h │ │ ├── PrivateHeaders/ │ │ │ └── XCTest/ │ │ │ ├── CDStructures.h │ │ │ ├── NSString-XCTAdditions.h │ │ │ ├── NSValue-XCTestAdditions.h │ │ │ ├── UIGestureRecognizer-RecordingAdditions.h │ │ │ ├── UILongPressGestureRecognizer-RecordingAdditions.h │ │ │ ├── UIPanGestureRecognizer-RecordingAdditions.h │ │ │ ├── UIPinchGestureRecognizer-RecordingAdditions.h │ │ │ ├── UISwipeGestureRecognizer-RecordingAdditions.h │ │ │ ├── UITapGestureRecognizer-RecordingAdditions.h │ │ │ ├── XCAXClient_iOS.h │ │ │ ├── XCActivityRecord.h │ │ │ ├── XCApplicationMonitor.h │ │ │ ├── XCApplicationMonitor_iOS.h │ │ │ ├── XCApplicationQuery.h │ │ │ ├── XCDebugLogDelegate-Protocol.h │ │ │ ├── XCEventGenerator.h │ │ │ ├── XCKeyMappingPath.h │ │ │ ├── XCKeyboardInputSolver.h │ │ │ ├── XCKeyboardKeyMap.h │ │ │ ├── XCKeyboardLayout.h │ │ │ ├── XCPointerEvent.h │ │ │ ├── XCPointerEventPath.h │ │ │ ├── XCSourceCodeRecording.h │ │ │ ├── XCSourceCodeTreeNode.h │ │ │ ├── XCSourceCodeTreeNodeEnumerator.h │ │ │ ├── XCSymbolicationRecord.h │ │ │ ├── XCSymbolicatorHolder.h │ │ │ ├── XCSynthesizedEventRecord.h │ │ │ ├── XCTAXClient-Protocol.h │ │ │ ├── XCTAsyncActivity-Protocol.h │ │ │ ├── XCTAsyncActivity.h │ │ │ ├── XCTAutomationTarget-Protocol.h │ │ │ ├── XCTDarwinNotificationExpectation.h │ │ │ ├── XCTElementSetTransformer-Protocol.h │ │ │ ├── XCTKVOExpectation.h │ │ │ ├── XCTMetric.h │ │ │ ├── XCTNSNotificationExpectation.h │ │ │ ├── XCTNSPredicateExpectation.h │ │ │ ├── XCTNSPredicateExpectationObject-Protocol.h │ │ │ ├── XCTRunnerAutomationSession.h │ │ │ ├── XCTRunnerDaemonSession.h │ │ │ ├── XCTRunnerIDESession.h │ │ │ ├── XCTTestRunSession.h │ │ │ ├── XCTTestRunSessionDelegate-Protocol.h │ │ │ ├── XCTUIApplicationMonitor-Protocol.h │ │ │ ├── XCTWaiter.h │ │ │ ├── XCTWaiterDelegate-Protocol.h │ │ │ ├── XCTWaiterDelegatePrivate-Protocol.h │ │ │ ├── XCTWaiterManagement-Protocol.h │ │ │ ├── XCTWaiterManager.h │ │ │ ├── XCTest.h │ │ │ ├── XCTestCase.h │ │ │ ├── XCTestCaseRun.h │ │ │ ├── XCTestCaseSuite.h │ │ │ ├── XCTestConfiguration.h │ │ │ ├── XCTestContext.h │ │ │ ├── XCTestContextScope.h │ │ │ ├── XCTestDriver.h │ │ │ ├── XCTestDriverInterface-Protocol.h │ │ │ ├── XCTestExpectation.h │ │ │ ├── XCTestExpectationDelegate-Protocol.h │ │ │ ├── XCTestExpectationWaiter.h │ │ │ ├── XCTestLog.h │ │ │ ├── XCTestManager_IDEInterface-Protocol.h │ │ │ ├── XCTestManager_ManagerInterface-Protocol.h │ │ │ ├── XCTestManager_TestsInterface-Protocol.h │ │ │ ├── XCTestMisuseObserver.h │ │ │ ├── XCTestObservation-Protocol.h │ │ │ ├── XCTestObservationCenter.h │ │ │ ├── XCTestObserver.h │ │ │ ├── XCTestProbe.h │ │ │ ├── XCTestRun.h │ │ │ ├── XCTestSuite.h │ │ │ ├── XCTestSuiteRun.h │ │ │ ├── XCTestWaiter.h │ │ │ ├── XCUIApplication.h │ │ │ ├── XCUIApplicationImpl.h │ │ │ ├── XCUIApplicationProcess.h │ │ │ ├── XCUICoordinate.h │ │ │ ├── XCUIDevice.h │ │ │ ├── XCUIElement.h │ │ │ ├── XCUIElementAsynchronousHandlerWrapper.h │ │ │ ├── XCUIElementHitPointCoordinate.h │ │ │ ├── XCUIElementQuery.h │ │ │ ├── XCUIHitPointResult.h │ │ │ ├── XCUIRecorderNodeFinder.h │ │ │ ├── XCUIRecorderNodeFinderMatch.h │ │ │ ├── XCUIRecorderTimingMessage.h │ │ │ ├── XCUIRecorderUtilities.h │ │ │ ├── XCUIScreen.h │ │ │ ├── XCUIScreenDataSource-Protocol.h │ │ │ ├── _XCInternalTestRun.h │ │ │ ├── _XCKVOExpectationImplementation.h │ │ │ ├── _XCTDarwinNotificationExpectationImplementation.h │ │ │ ├── _XCTNSNotificationExpectationImplementation.h │ │ │ ├── _XCTNSPredicateExpectationImplementation.h │ │ │ ├── _XCTWaiterImpl.h │ │ │ ├── _XCTestCaseImplementation.h │ │ │ ├── _XCTestCaseInterruptionException.h │ │ │ ├── _XCTestExpectationImplementation.h │ │ │ ├── _XCTestImplementation.h │ │ │ ├── _XCTestObservationCenterImplementation.h │ │ │ └── _XCTestSuiteImplementation.h │ │ ├── Routes/ │ │ │ ├── Extensions/ │ │ │ │ ├── Logger.swift │ │ │ │ ├── StringExtensions.swift │ │ │ │ └── XCUIElement+Extensions.swift │ │ │ ├── Handlers/ │ │ │ │ ├── DeviceInfoHandler.swift │ │ │ │ ├── EraseTextHandler.swift │ │ │ │ ├── InputTextRouteHandler.swift │ │ │ │ ├── KeyboardRouteHandler.swift │ │ │ │ ├── LaunchAppHandler.swift │ │ │ │ ├── PressButtonHandler.swift │ │ │ │ ├── PressKeyHandler.swift │ │ │ │ ├── RunningAppRouteHandler.swift │ │ │ │ ├── ScreenDiffHandler.swift │ │ │ │ ├── ScreenshotHandler.swift │ │ │ │ ├── SetOrientationHandler.swift │ │ │ │ ├── SetPermissionsHandler.swift │ │ │ │ ├── StatusHandler.swift │ │ │ │ ├── SwipeRouteHandler.swift │ │ │ │ ├── SwipeRouteHandlerV2.swift │ │ │ │ ├── TerminateAppHandler.swift │ │ │ │ ├── TouchRouteHandler.swift │ │ │ │ └── ViewHierarchyHandler.swift │ │ │ ├── Helpers/ │ │ │ │ ├── AppError.swift │ │ │ │ ├── ScreenSizeHelper.swift │ │ │ │ ├── SystemPermissionHelper.swift │ │ │ │ └── TextInputHelper.swift │ │ │ ├── Models/ │ │ │ │ ├── AXElement.swift │ │ │ │ ├── DeviceInfoResponse.swift │ │ │ │ ├── EraseTextRequest.swift │ │ │ │ ├── GetRunningAppRequest.swift │ │ │ │ ├── InputTextRequest.swift │ │ │ │ ├── KeyboardHandlerRequest.swift │ │ │ │ ├── KeyboardHandlerResponse.swift │ │ │ │ ├── LaunchAppRequest.swift │ │ │ │ ├── PressButtonRequest.swift │ │ │ │ ├── PressKeyRequest.swift │ │ │ │ ├── SetOrientationRequest.swift │ │ │ │ ├── SetPermissionsRequest.swift │ │ │ │ ├── StatusResponse.swift │ │ │ │ ├── SwipeRequest.swift │ │ │ │ ├── TerminateAppRequest.swift │ │ │ │ ├── TouchRequest.swift │ │ │ │ └── ViewHierarchyRequest.swift │ │ │ ├── RouteHandlerFactory.swift │ │ │ ├── XCTest/ │ │ │ │ ├── AXClientSwizzler.swift │ │ │ │ ├── EventRecord.swift │ │ │ │ ├── EventTarget.swift │ │ │ │ ├── KeyModifierFlags.swift │ │ │ │ ├── PointerEventPath.swift │ │ │ │ ├── RunnerDaemonProxy.swift │ │ │ │ └── RunningApp.swift │ │ │ └── XCTestHTTPServer.swift │ │ ├── Utilities/ │ │ │ ├── AXClientProxy.h │ │ │ ├── AXClientProxy.m │ │ │ ├── FBConfiguration.h │ │ │ ├── FBConfiguration.m │ │ │ ├── FBLogger.h │ │ │ ├── FBLogger.m │ │ │ ├── XCAccessibilityElement.h │ │ │ ├── XCTestDaemonsProxy.h │ │ │ └── XCTestDaemonsProxy.m │ │ ├── maestro_driver_iosUITests.swift │ │ └── maestro_driver_iosUITestsLaunchTests.swift │ ├── run-maestro-ios-runner.sh │ └── test-maestro-ios-runner.sh ├── maestro-orchestra/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── maestro/ │ │ └── orchestra/ │ │ ├── Orchestra.kt │ │ ├── error/ │ │ │ ├── InvalidFlowFile.kt │ │ │ ├── MediaFileNotFound.kt │ │ │ ├── NoInputException.kt │ │ │ ├── SyntaxError.kt │ │ │ ├── UnicodeNotSupportedError.kt │ │ │ └── ValidationError.kt │ │ ├── filter/ │ │ │ ├── FilterWithDescription.kt │ │ │ ├── LaunchArguments.kt │ │ │ └── TraitFilters.kt │ │ ├── geo/ │ │ │ └── Traveller.kt │ │ ├── util/ │ │ │ ├── AppMetadataAnalyzer.kt │ │ │ └── ElementCoordinateUtil.kt │ │ ├── validation/ │ │ │ ├── AppValidationException.kt │ │ │ ├── AppValidator.kt │ │ │ ├── WorkspaceValidationException.kt │ │ │ └── WorkspaceValidator.kt │ │ ├── workspace/ │ │ │ ├── ExecutionOrderPlanner.kt │ │ │ ├── Filters.kt │ │ │ ├── WorkspaceExecutionPlanner.kt │ │ │ ├── WorkspaceValidator.kt │ │ │ └── YamlCommandsPathValidator.kt │ │ └── yaml/ │ │ ├── MaestroFlowParser.kt │ │ ├── YamlAction.kt │ │ ├── YamlAddMedia.kt │ │ ├── YamlAssertNoDefectsWithAI.kt │ │ ├── YamlAssertScreenshot.kt │ │ ├── YamlAssertTrue.kt │ │ ├── YamlAssertWithAI.kt │ │ ├── YamlClearState.kt │ │ ├── YamlCommandReader.kt │ │ ├── YamlCondition.kt │ │ ├── YamlConfig.kt │ │ ├── YamlElementSelector.kt │ │ ├── YamlElementSelectorUnion.kt │ │ ├── YamlEraseTextUnion.kt │ │ ├── YamlEvalScript.kt │ │ ├── YamlExtendedWaitUntil.kt │ │ ├── YamlExtractTextWithAI.kt │ │ ├── YamlFluentCommand.kt │ │ ├── YamlInputRandomText.kt │ │ ├── YamlInputText.kt │ │ ├── YamlKillApp.kt │ │ ├── YamlLaunchApp.kt │ │ ├── YamlOnFlowComplete.kt │ │ ├── YamlOnFlowStart.kt │ │ ├── YamlOpenLink.kt │ │ ├── YamlPressKey.kt │ │ ├── YamlRepeatCommand.kt │ │ ├── YamlRetry.kt │ │ ├── YamlRunFlow.kt │ │ ├── YamlRunScript.kt │ │ ├── YamlScrollUntilVisible.kt │ │ ├── YamlSetAirplaneMode.kt │ │ ├── YamlSetClipboard.kt │ │ ├── YamlSetLocation.kt │ │ ├── YamlSetOrientation.kt │ │ ├── YamlSetPermissions.kt │ │ ├── YamlStartRecording.kt │ │ ├── YamlStopApp.kt │ │ ├── YamlSwipe.kt │ │ ├── YamlTakeScreenshot.kt │ │ ├── YamlToggleAirplaneMode.kt │ │ ├── YamlTravelCommand.kt │ │ └── YamlWaitForAnimationToEndCommand.kt │ └── test/ │ ├── java/ │ │ └── maestro/ │ │ └── orchestra/ │ │ ├── CommandDescriptionTest.kt │ │ ├── LaunchArgumentsTest.kt │ │ ├── MaestroCommandSerializationTest.kt │ │ ├── MaestroCommandTest.kt │ │ ├── android/ │ │ │ ├── AndroidMediaStoreTest.kt │ │ │ └── DadbExt.kt │ │ ├── util/ │ │ │ ├── AppMetadataAnalyzerTest.kt │ │ │ └── ElementCoordinateUtilTest.kt │ │ ├── validation/ │ │ │ ├── AppValidatorTest.kt │ │ │ └── WorkspaceValidatorTest.kt │ │ ├── workspace/ │ │ │ ├── ExecutionOrderPlannerTest.kt │ │ │ ├── WorkspaceExecutionPlannerErrorsTest.kt │ │ │ ├── WorkspaceExecutionPlannerTest.kt │ │ │ └── WorkspaceValidatorTest.kt │ │ └── yaml/ │ │ ├── YamlCommandReaderTest.kt │ │ └── junit/ │ │ ├── YamlCommandsExtension.kt │ │ ├── YamlFile.kt │ │ └── YamlResourceFile.kt │ └── resources/ │ ├── YamlCommandReaderTest/ │ │ ├── 002_launchApp.yaml │ │ ├── 003_launchApp_withClearState.yaml │ │ ├── 008_config_unknownKeys.yaml │ │ ├── 017_launchApp_otherPackage.yaml │ │ ├── 018_backPress_string.yaml │ │ ├── 019_scroll_string.yaml │ │ ├── 020_config_name.yaml │ │ ├── 022_on_flow_start_complete.yaml │ │ ├── 023_labels.yaml │ │ ├── 023_runScript_test.js │ │ ├── 024_string_non_string_commands.yaml │ │ ├── 025_killApp.yaml │ │ ├── 027_waitToSettleTimeoutMs.yaml │ │ ├── 028_inputRandomAnimal.yaml │ │ ├── 029_command_descriptions.yaml │ │ ├── 029_double_tap_element_relative.yaml │ │ ├── 029_element_relative_tap_css.yaml │ │ ├── 029_element_relative_tap_enabled.yaml │ │ ├── 029_element_relative_tap_id_absolute.yaml │ │ ├── 029_element_relative_tap_index.yaml │ │ ├── 029_element_relative_tap_label.yaml │ │ ├── 029_element_relative_tap_size.yaml │ │ ├── 029_element_relative_tap_text_percentage.yaml │ │ ├── 029_element_relative_tap_with_repeat.yaml │ │ ├── 029_pure_point_tap.yaml │ │ ├── 029_regular_element_tap.yaml │ │ ├── 030_setPermissions.yaml │ │ ├── 031_setOrientation.yaml │ │ └── 032_setOrientation_error.yaml │ ├── media/ │ │ ├── android/ │ │ │ ├── add_media_gif.yaml │ │ │ ├── add_media_jpeg.yaml │ │ │ ├── add_media_jpg.yaml │ │ │ ├── add_media_mp4.yaml │ │ │ ├── add_media_png.yaml │ │ │ └── add_multiple_media.yaml │ │ └── ios/ │ │ ├── add_media_gif.yaml │ │ ├── add_media_jpeg.yaml │ │ ├── add_media_jpg.yaml │ │ ├── add_media_mp4.yaml │ │ ├── add_media_png.yaml │ │ └── add_multiple_media.yaml │ └── workspaces/ │ ├── .gitignore │ ├── 000_individual_file/ │ │ └── flow.yaml │ ├── 001_simple/ │ │ ├── flowA.yaml │ │ ├── flowB.yaml │ │ └── notAFlow.txt │ ├── 002_subflows/ │ │ ├── flowA.yaml │ │ ├── flowB.yaml │ │ └── subflows/ │ │ └── subflow.yaml │ ├── 003_include_tags/ │ │ ├── flowA.yaml │ │ ├── flowB.yaml │ │ └── flowC.yaml │ ├── 004_exclude_tags/ │ │ ├── flowA.yaml │ │ ├── flowB.yaml │ │ └── flowC.yaml │ ├── 005_custom_include_pattern/ │ │ ├── config.yaml │ │ ├── featureA/ │ │ │ └── flowA.yaml │ │ ├── featureB/ │ │ │ └── flowB.yaml │ │ ├── featureC/ │ │ │ └── flowC.yaml │ │ └── flowD.yaml │ ├── 006_include_subfolders/ │ │ ├── config.yaml │ │ ├── featureA/ │ │ │ └── flowA.yaml │ │ ├── featureB/ │ │ │ └── flowB.yaml │ │ ├── featureC/ │ │ │ └── subfolder/ │ │ │ └── flowC.yaml │ │ └── flowD.yaml │ ├── 007_empty_config/ │ │ ├── config.yml │ │ ├── flowA.yaml │ │ └── flowB.yaml │ ├── 008_literal_pattern/ │ │ ├── config.yaml │ │ ├── featureA/ │ │ │ └── flowA.yaml │ │ └── featureB/ │ │ └── flowB.yaml │ ├── 009_custom_config_fields/ │ │ ├── config.yml │ │ ├── flowA.yaml │ │ └── flowB.yaml │ ├── 010_global_include_tags/ │ │ ├── config.yaml │ │ ├── flowA.yaml │ │ ├── flowA_subflow.yaml │ │ ├── flowB.yaml │ │ ├── flowC.yaml │ │ ├── flowD.yaml │ │ └── flowE.yaml │ ├── 011_global_exclude_tags/ │ │ ├── config.yaml │ │ ├── flowA.yaml │ │ ├── flowA_subflow.yaml │ │ ├── flowB.yaml │ │ ├── flowC.yaml │ │ ├── flowD.yaml │ │ └── flowE.yaml │ ├── 013_execution_order/ │ │ ├── config.yaml │ │ ├── flowA.yaml │ │ ├── flowB.yaml │ │ ├── flowCWithCustomName.yaml │ │ └── flowD.yaml │ ├── 014_config_not_null/ │ │ ├── config/ │ │ │ └── another_config.yaml │ │ ├── config.yaml │ │ ├── flowA.yaml │ │ └── flowB.yaml │ ├── 015_workspace_cloud_configs/ │ │ ├── config.yaml │ │ ├── flowA.yaml │ │ └── flowB.yaml │ ├── e000_flow_path_does_not_exist/ │ │ └── error.txt │ ├── e001_directory_does_not_contain_flow_files/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── dummy │ ├── e002_top_level_directory_does_not_contain_flow_files/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── subdir/ │ │ └── Flow.yaml │ ├── e003_flow_inclusion_pattern_does_not_match_any_flow_files/ │ │ ├── error.txt │ │ └── workspace/ │ │ ├── FlowC.yaml │ │ └── config.yaml │ ├── e004_tags_config_does_not_match_any_flow_files/ │ │ ├── error.txt │ │ ├── excludeTags.txt │ │ ├── includeTags.txt │ │ └── workspace/ │ │ ├── ConfigExclude.yaml │ │ ├── ParameterExclude.yaml │ │ └── config.yaml │ ├── e005_single_flow_does_not_exist/ │ │ ├── error.txt │ │ └── singleFlow.txt │ ├── e006_single_flow_invalid_string_command/ │ │ ├── error.txt │ │ ├── singleFlow.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e007_single_flow_malformatted_command/ │ │ ├── error.txt │ │ ├── singleFlow.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e008_subflow_invalid_string_command/ │ │ ├── error.txt │ │ └── workspace/ │ │ ├── Flow.yaml │ │ └── subflow/ │ │ └── SubFlow.yaml │ ├── e009_nested_subflow_invalid_string_command/ │ │ ├── error.txt │ │ └── workspace/ │ │ ├── Flow.yaml │ │ └── subflow/ │ │ ├── SubFlowA.yaml │ │ └── SubFlowB.yaml │ ├── e010_missing_config_section/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e011_missing_dashes/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e012_invalid_subflow_path/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e013_invalid_media_file/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e014_invalid_media_file_outside/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e015_array_command/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e016_config_invalid_command_in_onFlowStart/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e017_config_invalid_tags/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e018_config_missing_appId/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e019_invalid_swipe_direction/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e020_missing_command_options/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e021_multiple_command_names/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e022_top_level_option/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e023_empty/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e023_empty_commands/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ ├── e023_launchApp_empty_string/ │ │ ├── error.txt │ │ └── workspace/ │ │ └── Flow.yaml │ └── workspace_validator_flow.yaml ├── maestro-orchestra-models/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── maestro/ │ │ └── orchestra/ │ │ ├── Commands.kt │ │ ├── Condition.kt │ │ ├── ElementSelector.kt │ │ ├── ElementTrait.kt │ │ ├── MaestroCommand.kt │ │ ├── MaestroConfig.kt │ │ ├── WorkspaceConfig.kt │ │ └── util/ │ │ └── Env.kt │ └── test/ │ └── kotlin/ │ └── maestro/ │ └── orchestra/ │ ├── CommandsTest.kt │ ├── ElementSelectorTest.kt │ └── util/ │ └── EnvTest.kt ├── maestro-proto/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ └── proto/ │ └── maestro_android.proto ├── maestro-studio/ │ ├── server/ │ │ ├── .gitignore │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ └── java/ │ │ └── maestro/ │ │ └── studio/ │ │ ├── AuthService.kt │ │ ├── DeviceService.kt │ │ ├── HttpException.kt │ │ ├── InsightService.kt │ │ ├── KtorUtils.kt │ │ ├── MaestroStudio.kt │ │ ├── MockService.kt │ │ └── Models.kt │ └── web/ │ ├── .gitignore │ ├── .npmrc │ ├── .nvmrc │ ├── build.gradle │ ├── package.json │ ├── postcss.config.js │ ├── public/ │ │ └── index.html │ ├── src/ │ │ ├── App.tsx │ │ ├── api/ │ │ │ └── api.ts │ │ ├── components/ │ │ │ ├── commands/ │ │ │ │ ├── CommandCreator.tsx │ │ │ │ ├── CommandInput.tsx │ │ │ │ ├── CommandList.tsx │ │ │ │ ├── CommandRow.tsx │ │ │ │ ├── ReplHeader.tsx │ │ │ │ ├── ReplView.tsx │ │ │ │ └── SaveFlowModal.tsx │ │ │ ├── common/ │ │ │ │ ├── AuthModal.tsx │ │ │ │ ├── Banner.tsx │ │ │ │ ├── ChatGptApiKeyModal.tsx │ │ │ │ ├── ConfirmationDialog.tsx │ │ │ │ ├── Header.tsx │ │ │ │ ├── Modal.tsx │ │ │ │ ├── PageSwitcher.tsx │ │ │ │ └── theme.tsx │ │ │ ├── design-system/ │ │ │ │ ├── button.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── keyboard-key.tsx │ │ │ │ ├── link.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ └── utils/ │ │ │ │ ├── functions.tsx │ │ │ │ └── images.tsx │ │ │ ├── device-and-device-elements/ │ │ │ │ ├── ActionModal.tsx │ │ │ │ ├── AnnotatedScreenshot.tsx │ │ │ │ ├── BrowserActionBar.tsx │ │ │ │ ├── DeviceWrapperAspectRatio.tsx │ │ │ │ ├── ElementsPanel.tsx │ │ │ │ ├── InteractableDevice.tsx │ │ │ │ └── SelectedElementViewer.tsx │ │ │ └── interact/ │ │ │ └── InteractPageLayout.tsx │ │ ├── context/ │ │ │ ├── AuthContext.tsx │ │ │ ├── DeviceContext.tsx │ │ │ └── ReplContext.tsx │ │ ├── helpers/ │ │ │ ├── commandExample.ts │ │ │ ├── models.ts │ │ │ └── sampleElements.ts │ │ ├── index.tsx │ │ ├── pages/ │ │ │ └── InteractPage.tsx │ │ ├── react-app-env.d.ts │ │ └── style/ │ │ └── index.css │ ├── tailwind.config.js │ └── tsconfig.json ├── maestro-test/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── maestro/ │ │ └── test/ │ │ └── drivers/ │ │ ├── FakeDriver.kt │ │ ├── FakeLayoutElement.kt │ │ └── FakeTimer.kt │ └── test/ │ ├── kotlin/ │ │ └── maestro/ │ │ └── test/ │ │ ├── DeepestMatchingElementTest.kt │ │ ├── FlowControllerTest.kt │ │ ├── GraalJsEngineTest.kt │ │ ├── IntegrationTest.kt │ │ ├── JsEngineTest.kt │ │ └── RhinoJsEngineTest.kt │ └── resources/ │ ├── 001_assert_visible_by_id.yaml │ ├── 002_assert_visible_by_text.yaml │ ├── 003_assert_visible_by_size.yaml │ ├── 004_assert_no_visible_element_with_id.yaml │ ├── 005_assert_no_visible_element_with_text.yaml │ ├── 006_assert_no_visible_element_with_size.yaml │ ├── 007_assert_visible_by_size_with_tolerance.yaml │ ├── 008_tap_on_element.yaml │ ├── 009_skip_optional_elements.yaml │ ├── 010_scroll.yaml │ ├── 011_back_press.yaml │ ├── 012_input_text.yaml │ ├── 013_launch_app.yaml │ ├── 014_tap_on_point.yaml │ ├── 015_element_relative_position.yaml │ ├── 016_multiline_text.yaml │ ├── 017_swipe.yaml │ ├── 018_contains_child.yaml │ ├── 019_dont_wait_for_visibility.yaml │ ├── 020_parse_config.yaml │ ├── 021_launch_app_with_clear_state.yaml │ ├── 022_launch_app_that_is_not_installed.yaml │ ├── 025_element_relative_position_shortcut.yaml │ ├── 026_assert_not_visible.yaml │ ├── 027_open_link.yaml │ ├── 028_env.yaml │ ├── 029_long_press_on_element.yaml │ ├── 030_long_press_on_point.yaml │ ├── 031_traits.yaml │ ├── 032_element_index.yaml │ ├── 033_int_text.yaml │ ├── 034_press_key.yaml │ ├── 035_refresh_position_ignore_duplicates.yaml │ ├── 036_erase_text.yaml │ ├── 037_unicode_input.yaml │ ├── 038_partial_id.yaml │ ├── 039_hide_keyboard.yaml │ ├── 040_escape_regex.yaml │ ├── 041_take_screenshot.yaml │ ├── 042_extended_wait.yaml │ ├── 043_stop_app.yaml │ ├── 044_clear_state.yaml │ ├── 045_clear_keychain.yaml │ ├── 046_run_flow.yaml │ ├── 047_run_flow_nested.yaml │ ├── 048_tapOn_clickable.yaml │ ├── 049_run_flow_conditionally.yaml │ ├── 051_set_location.yaml │ ├── 052_text_random.yaml │ ├── 053_repeat_times.yaml │ ├── 054_enabled.yaml │ ├── 055_compare_regex.yaml │ ├── 056_ignore_error.yaml │ ├── 057_runFlow_env.yaml │ ├── 057_subflow.yaml │ ├── 057_subflow_override.yaml │ ├── 058_inline_env.yaml │ ├── 058_subflow.yaml │ ├── 059_directional_swipe_command.yaml │ ├── 060_pass_env_to_env.yaml │ ├── 060_subflow.yaml │ ├── 061_launchApp_withoutStopping.yaml │ ├── 062_copy_paste_text.yaml │ ├── 063_js_injection.yaml │ ├── 064_js_files.yaml │ ├── 064_script.js │ ├── 064_script_alt.js │ ├── 064_script_with_args.js │ ├── 064_subflow.yaml │ ├── 065_subflow.yaml │ ├── 065_when_true.yaml │ ├── 066_copyText_jsVar.yaml │ ├── 067_assertTrue_fail.yaml │ ├── 067_assertTrue_pass.yaml │ ├── 068_erase_all_text.yaml │ ├── 069_wait_for_animation_to_end.yaml │ ├── 070_evalScript.yaml │ ├── 071_tapOnRelativePoint.yaml │ ├── 072_searchDepthFirst.yaml │ ├── 073_handle_linebreaks.yaml │ ├── 074_directional_swipe_element.yaml │ ├── 075_repeat_while.yaml │ ├── 076_optional_assertion.yaml │ ├── 077_env_special_characters.yaml │ ├── 078_swipe_relative.yaml │ ├── 079_scroll_until_visible.yaml │ ├── 080_hierarchy_pruning_assert_visible.yaml │ ├── 081_hierarchy_pruning_assert_not_visible.yaml │ ├── 082_repeat_while_true.yaml │ ├── 083_assert_properties.yaml │ ├── 084_open_browser.yaml │ ├── 085_open_link_auto_verify.yaml │ ├── 086_launchApp_sets_all_permissions_to_allow.yaml │ ├── 087_launchApp_with_all_permissions_to_deny.yaml │ ├── 088_launchApp_with_all_permissions_to_deny_and_notification_to_allow.yaml │ ├── 089_launchApp_with_sms_permission_group_to_allow.yaml │ ├── 090_travel.yaml │ ├── 091_assert_visible_by_index.yaml │ ├── 092_log_messages.yaml │ ├── 092_script.js │ ├── 093_js_default_value.yaml │ ├── 094_runFlow_inline.yaml │ ├── 095_launch_arguments.yaml │ ├── 096_platform_condition.yaml │ ├── 097_contains_descendants.yaml │ ├── 098_runScript.js │ ├── 098_runscript_conditionals.yaml │ ├── 098_runscript_conditionals_eager.yaml │ ├── 099_screen_recording.yaml │ ├── 100_tapOn_multiple_times.yaml │ ├── 101_doubleTapOn.yaml │ ├── 102_graaljs.yaml │ ├── 102_graaljs_subflow.yaml │ ├── 103_on_flow_start_complete_hooks.yaml │ ├── 103_setup.js │ ├── 103_teardown.js │ ├── 104_on_flow_start_complete_hooks_flow_failed.yaml │ ├── 105_on_flow_start_complete_when_js_output_set.yaml │ ├── 105_setup.js │ ├── 105_teardown.js │ ├── 106_on_flow_start_complete_when_js_output_set_subflows.yaml │ ├── 106_setup.js │ ├── 106_subflow.yaml │ ├── 106_teardown.js │ ├── 107_define_variables_command_before_hooks.yaml │ ├── 108_failed_start_hook.yaml │ ├── 109_failed_complete_hook.yaml │ ├── 110_add_media_device.yaml │ ├── 111_add_multiple_media.yaml │ ├── 112_scroll_until_visible_center.yaml │ ├── 113_tap_on_element_settle_timeout.yaml │ ├── 114_child_of_selector.yaml │ ├── 115_airplane_mode.yaml │ ├── 116_kill_app.yaml │ ├── 117_scroll_until_visible_speed.js │ ├── 117_scroll_until_visible_speed.yaml │ ├── 118_scroll_until_visible_negative.yaml │ ├── 119_retry_commands.yaml │ ├── 120_tap_on_element_retryTapIfNoChange.yaml │ ├── 122_pause_resume.yaml │ ├── 123_pause_resume_preserves_js_engine.yaml │ ├── 124_cancellation_during_flow_execution.yaml │ ├── 125_assert_by_css.yaml │ ├── 126_set_orientation.yaml │ ├── 126_set_orientation_with_env.yaml │ ├── 127_env_vars_isolation_graaljs.yaml │ ├── 127_env_vars_isolation_rhinojs.yaml │ ├── 127_script.js │ ├── 127_script_mutate_env_var.js │ ├── 128_datafaker_graaljs.yaml │ ├── 129_text_and_id.yaml │ ├── 130_text_and_index.yaml │ ├── 131_setPermissions.yaml │ ├── 132_repeat_while_timeout.yaml │ ├── 133_setClipboard.yaml │ ├── 134_take_screenshot_with_path.yaml │ ├── 135_screen_recording_with_path.yaml │ ├── 136_js_http_multi_part_requests.yaml │ ├── 137_shard_device_env_vars.yaml │ ├── 138_take_cropped_screenshot.yaml │ └── script/ │ └── multipart_request_file_script.js ├── maestro-utils/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ ├── Collections.kt │ │ ├── DepthTracker.kt │ │ ├── HttpClient.kt │ │ ├── Insight.kt │ │ ├── Insights.kt │ │ ├── MaestroTimer.kt │ │ ├── Metrics.kt │ │ ├── SocketUtils.kt │ │ ├── Strings.kt │ │ ├── TempFileHandler.kt │ │ └── network/ │ │ └── Errors.kt │ └── test/ │ └── kotlin/ │ ├── CollectionsTest.kt │ ├── DepthTrackerTest.kt │ ├── InsightTest.kt │ ├── MaestroTimerTest.kt │ ├── SocketUtilsTest.kt │ ├── StringsTest.kt │ └── network/ │ └── ErrorsTest.kt ├── maestro-web/ │ ├── build.gradle.kts │ ├── gradle.properties │ └── src/ │ └── main/ │ └── kotlin/ │ └── maestro/ │ └── web/ │ ├── cdp/ │ │ └── CdpClient.kt │ ├── record/ │ │ ├── JcodecVideoEncoder.kt │ │ ├── VideoEncoder.kt │ │ └── WebScreenRecorder.kt │ └── selenium/ │ ├── ChromeSeleniumFactory.kt │ └── SeleniumFactory.kt ├── scripts/ │ └── install.sh ├── settings.gradle.kts └── tmp.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # Copied from https://youtrack.jetbrains.com/issue/FL-15599/No-way-of-disabling-Java-Kotlin-wildcard-imports [*.java] ij_java_class_count_to_use_import_on_demand = 1024 ij_java_names_count_to_use_import_on_demand = 1024 [*.kt] ij_kotlin_name_count_to_use_star_import = 1024 ij_kotlin_name_count_to_use_star_import_for_members = 1024 ================================================ FILE: .gitattributes ================================================ # # https://help.github.com/articles/dealing-with-line-endings/ # # These are explicitly windows files and should use crlf *.bat text eol=crlf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Report a bug description: You have a problem with Maestro. body: - type: markdown attributes: value: > ### Thank you for using Maestro! Before creating a new issue, please first search the [existing issues] and make sure it hasn't been reported before. If you are sure that you have found a bug that hasn't been reported yet, or if our documentation doesn't have an answer to what you're looking for, then please fill out this template. --- [existing issues]: https://github.com/mobile-dev-inc/maestro - type: checkboxes attributes: label: Is there an existing issue for this? description: | Please search to see if an issue already exists for the bug you encountered. options: - label: I have searched the existing issues and didn't find mine. required: true - type: textarea validations: required: true attributes: label: Steps to reproduce description: > Create a [minimal, reproducible example] that: 1. Demonstrates the problem 2. Explains how to reproduce the problem with detailed step-by-step instructions **In addition to the detailed step-by-step instructions**, you must include information about the device you're encountering the issue on (e.g. physical Android or iOS simulator), and the OS version (e.g. Android 9, Android 14 with Play Services, or iOS 18). **It's critical that you include your test flow file**. In general, try to include as much additional details as possible to make it easier for us to understand and fix the problem. Screenshots and videos are welcome. > [!TIP] > If you're recording a video on Android, we recommend enabling these options to show taps and gestures: > ``` > adb shell settings put system show_touches 1 > adb shell settings put system pointer_location 1 > ``` > [!WARNING] > Issues that cannot be reproduced are much more likely to be closed. [minimal, reproducible example]: https://stackoverflow.com/help/minimal-reproducible-example placeholder: | Example good reproduction steps: 1. Clone https://github.com/your_username/your_repo_with_bug and `cd` into it 2. Start Android emulator (Pixel 7, API 34, with Google Play) 3. Build app: `./gradlew :app:assembleDebug` 4. Run the problematic flow and see it fail: `maestro test .maestro/flow.yaml` - type: textarea validations: required: true attributes: label: Actual results description: Please explain what is happening. - type: textarea validations: required: true attributes: label: Expected results description: Please explain what you expect to happen. - type: textarea validations: required: true attributes: label: About app description: > Include information about the app you're testing: - Is this an open source or closed source project? - If open source, please share link to the repo - If closed source, please share app binary and/or an isolated, reproducible sample - Is this a native or cross-platform app? - Framework used to build the app - e.g. UIKit, SwiftUI, Android Views, Compose, React Native, or NativeScript - If applicable, version of the framework (e.g. Flutter 3.22.0, Compose 1.62.0) - If applicable, minimum and target Android SDK/iOS version (e.g. minSdk 21, targetSdk 34) placeholder: | The info you enter here will make it easier to resolve your issue. For example: - This is an open source app, available at https://github.com/wikimedia/wikipedia-ios - It's a native iOS app. There is also an Android version, but the issue is only on iOS. - It's built mainly with UIKit, minimum iOS deployment target is 13.0 - type: textarea validations: required: true attributes: label: About environment description: | Include information about machine you're running Maestro on: - Java version (e.g. OpenJDK 17, Eclipse Temurin 8). To find it, run `java -version` - OS and its version (e.g. macOS 13.1 Ventura, Ubuntu 24.04, Arch (btw)) - Processor architecture (x86_64, arm64) placeholder: | The info you enter here will make it easier to resolve your issue. For example: - I'm on M1 MacBook Air, with macOS 14.5 Sonoma and Xcode 15.4. - type: textarea attributes: label: Logs description: > Include the full logs of the command you're running. The zip files created with `maestro bugreport` can be uploaded here as well. Things to keep in mind: - If you're running more than single command, include its logs in a separate backticks block. - If the logs are too large to be uploaded to Github, you may upload them as a `txt` file or use online tools like https://pastebin.com and share the link. Just make sure the link won't break in the future. - **Do not upload screenshots of text**. Instead, use code blocks or the above mentioned ways to upload logs. - **Make sure the logs are well formatted**. If you post garbled logs, it will make it harder for us to help you. value: |
Logs ``` ```
- type: input validations: required: true attributes: label: Maestro version description: > Provide version of Maestro CLI where the problem occurs. Run `maestro --version` to find out. placeholder: 1.36.0 - type: dropdown validations: required: true attributes: label: How did you install Maestro? options: - install script (https://get.maestro.mobile.dev) - Homebrew - built from source (please include commit hash in the text area below) - other (please specify in the text area below) default: 0 - type: textarea validations: required: false attributes: label: Anything else? description: > Links? Other issues? StackOverflow threads? Anything that will give us more context about the issue you are encountering will be helpful. > [!TIP] > You can attach images or log files by clicking this area to highlight it and then dragging files in. - type: markdown attributes: value: > Now that you've filled all the required information above, you're ready to submit the issue. **Please check what your issue looks like after creating it**. If it contains garbled code and logs, please take some time to adjust it so it's easier to parse. **Try reading your issue as if you were seeing it for the first time**. Does it read well? Is it easy to understand? Is the formatting correct? If not, please improve it. Thank you for helping us improve Maestro and keeping our issue tracker in a good shape! ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Suggest a feature description: You want to share a new idea to improve Maestro. body: - type: markdown attributes: value: > ### Thank you for using Maestro! We can't wait to hear your idea! First though, please search the [existing issues] to see if an issue already exists for the feature you need. Maybe someone already did the job for you and you don't need to fill this template. --- If you are sure that the feature you want to suggest hasn't been requested before, or if our documentation doesn't have an answer to what you're looking for, then fill out the template below. Please bear in mind that duplicates and insufficiently described feature requests will be closed. [existing issues]: https://github.com/mobile-dev-inc/maestro/issues - type: textarea attributes: label: Use case description: > Please tell us more about the use case you have that led to you wanting this new feature. Is your feature request related to a problem? Please give a clear and concise description of what the problem is. This will help avoid the [XY problem]. Describe the alternative solutions you've considered and the tradeoffs they come with. The more context you can provide, the better. [XY problem]: https://en.wikipedia.org/wiki/XY_problem validations: required: true - type: textarea attributes: label: Proposal description: > Briefly but precisely describe what the new feature should look like from the user perspective. Consider attaching something showing what you are imagining: * code samples (maybe you already know ) * API design ideas (e.g. of new YAML commands) validations: required: true - type: textarea validations: required: false attributes: label: Anything else? description: > Links? Other issues? StackOverflow threads? Anything that will give us more context about this feature request will be helpful. > [!TIP] > You can attach images or other files by clicking this area to highlight it and then dragging files in. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Proposed changes copilot:summary ## Testing > **Does this need e2e tests?** Please consider contributing them to the [demo app](https://github.com/mobile-dev-inc/demo_app) repository. ## Issues fixed ================================================ FILE: .github/scripts/boot_simulator.sh ================================================ #!/bin/bash # Specify the device type and runtime as per your requirements DEVICE_TYPE="${DEVICE_TYPE:-iPhone 15 Pro}" RUNTIME="${RUNTIME:-iOS18.6}" # Create a unique identifier for the new simulator to avoid naming conflicts SIMULATOR_NAME="Simulator_$(uuidgen)" echo "Creating a new iOS simulator: $SIMULATOR_NAME (Device: $DEVICE_TYPE, Runtime: $RUNTIME)" # Create the simulator simulator_id=$(xcrun simctl create "$SIMULATOR_NAME" "$DEVICE_TYPE" $RUNTIME) echo "Simulator ID: $simulator_id created." # Boot the simulator echo "Booting the simulator..." xcrun simctl boot "$simulator_id" # Wait for the simulator to be fully booted while true; do # Check the current state of the simulator state=$(xcrun simctl list | grep "$simulator_id" | grep -o "Booted" || true) if [ "$state" == "Booted" ]; then echo "Simulator $SIMULATOR_NAME is now ready." break else echo "Waiting for the simulator to be ready..." sleep 5 # sleep for 5 seconds before checking again to avoid spamming fi done ================================================ FILE: .github/workflows/close-inactive-issues.yaml ================================================ # Close issues that have had "waiting for customer response" label for too long. # This workflow is based on a very similar one from Flutter # https://github.com/flutter/flutter/blob/3.22.0/.github/workflows/no-response.yaml name: close inactive issues on: issue_comment: types: [created] schedule: - cron: '0 */6 * * *' permissions: issues: write jobs: main: runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - uses: godofredoc/no-response@0ce2dc0e63e1c7d2b87752ceed091f6d32c9df09 with: token: ${{ github.token }} closeComment: > Without additional information, we can't resolve this issue. We're therefore reluctantly going to close it. Feel free to open a new issue with all the required information provided, including a [minimal, reproducible sample]. When creating a new issue, please make sure to diligently fill out the issue template. Thank you for your contribution to our open-source community! [minimal, reproducible sample]: https://stackoverflow.com/help/minimal-reproducible-example # Number of days of inactivity before an issue is closed. daysUntilClose: 14 # Only issues with this label will be closed (if they are inactive). responseRequiredLabel: waiting for customer response ================================================ FILE: .github/workflows/lock-closed-issues.yaml ================================================ # Lock closed issues that have been inactive for a while. # This workflow is copied from Flutter # https://github.com/flutter/flutter/blob/3.22.0/.github/workflows/lock.yaml name: lock closed issues permissions: issues: write on: schedule: - cron: '0 */6 * * *' jobs: lock: permissions: issues: write runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - uses: dessant/lock-threads@v5 with: process-only: issues github-token: ${{ github.token }} # Number of days of inactivity before a closed issue is locked. issue-inactive-days: 7 issue-comment: > This issue has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar problem, please file a new issue. Make sure to follow the template and provide all the information necessary to reproduce the issue. Thank you for helping keep us our issue tracker clean! ================================================ FILE: .github/workflows/publish-cli.yaml ================================================ name: Publish CLI on: workflow_dispatch: jobs: publish: runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - name: Clone repository uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 cache: gradle - name: Publish CLI run: ./gradlew :maestro-cli:jreleaserFullRelease --no-daemon --no-parallel env: JRELEASER_GITHUB_TOKEN: ${{ secrets.JRELEASER_GITHUB_TOKEN }} - name: Print jReleaser log if: always() run: cat maestro-cli/build/jreleaser/trace.log ================================================ FILE: .github/workflows/publish-release.yaml ================================================ name: Publish Release on: workflow_dispatch: push: tags: - 'v*' env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }} jobs: publish: runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - name: Clone repository uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 cache: gradle - name: Retrieve version run: | echo "VERSION_NAME=$(cat gradle.properties | grep -w "VERSION_NAME" | cut -d'=' -f2)" >> $GITHUB_ENV - name: Upload Maestro utils release run: ./gradlew clean :maestro-utils:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro client release run: ./gradlew clean :maestro-client:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro ios release run: ./gradlew clean :maestro-ios:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro orchestra release run: ./gradlew clean :maestro-orchestra:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro Orchestra Models release run: ./gradlew clean :maestro-orchestra-models:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro Proto release run: ./gradlew clean :maestro-proto:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro XCUiTest Driver run: ./gradlew clean :maestro-ios-driver:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro AI release run: ./gradlew clean :maestro-ai:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro Web release run: ./gradlew clean :maestro-web:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} - name: Upload Maestro CLI release run: ./gradlew clean :maestro-cli:publishToMavenCentral --no-daemon --no-parallel if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }} env: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }} ================================================ FILE: .github/workflows/publish-snapshot.yaml ================================================ name: Publish Snapshot on: workflow_dispatch: push: branches: - main jobs: publish: runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - name: Clone repository uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 cache: gradle - name: Retrieve version run: | echo "VERSION_NAME=$(cat gradle.properties | grep -w "VERSION_NAME" | cut -d'=' -f2)" >> $GITHUB_ENV - name: Upload Maestro utils release run: ./gradlew clean :maestro-utils:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro client release run: ./gradlew clean :maestro-client:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro ios release run: ./gradlew clean :maestro-ios:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro orchestra release run: ./gradlew clean :maestro-orchestra:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro Orchestra Models release run: ./gradlew clean :maestro-orchestra-models:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro Proto release run: ./gradlew clean :maestro-proto:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro XCUiTest Driver run: ./gradlew clean :maestro-ios-driver:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro AI run: ./gradlew clean :maestro-ai:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - name: Upload Maestro web run: ./gradlew clean :maestro-web:publish --no-daemon --no-parallel if: endsWith(env.VERSION_NAME, '-SNAPSHOT') env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} ================================================ FILE: .github/workflows/test-e2e-ios-intel.yaml ================================================ name: Test E2E on iOS (Intel) on: workflow_dispatch: jobs: build: name: Build on Java ${{ matrix.java-version }} runs-on: macos-latest timeout-minutes: 20 strategy: fail-fast: false matrix: java-version: [17] steps: - name: Clone repository uses: actions/checkout@v6 - name: Set up Java uses: actions/setup-java@v5 with: distribution: zulu java-version: ${{ matrix.java-version }} cache: gradle # Do not rebuild this - let's test the one that is in the repo #- name: Build xctest-runner # run: ./maestro-ios-xctest-runner/build-maestro-ios-runner.sh | xcbeautify - name: Build Maestro CLI run: ./gradlew :maestro-cli:distZip - name: Upload zipped Maestro CLI artifact uses: actions/upload-artifact@v6 with: name: maestro-cli-jdk${{ matrix.java-version }}-run_id${{ github.run_id }} path: maestro-cli/build/distributions/maestro.zip retention-days: 1 - name: Upload build/Products to artifacts uses: actions/upload-artifact@v6 with: name: build__Products-jdk${{ matrix.java-version }} path: build/Products retention-days: 1 test-ios: name: Test on iOS runs-on: macos-15-intel needs: build timeout-minutes: 120 env: MAESTRO_DRIVER_STARTUP_TIMEOUT: 240000 # 240s MAESTRO_CLI_LOG_PATTERN_CONSOLE: '%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n' steps: - name: Clone repository (only needed for the e2e directory) uses: actions/checkout@v6 - name: Set up JDK uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 - name: Download artifacts uses: actions/download-artifact@v7 with: name: maestro-cli-jdk17-run_id${{ github.run_id }} - name: Add Maestro CLI executable to PATH run: | unzip maestro.zip -d maestro_extracted echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH - name: Check if Maestro CLI executable starts up run: | maestro --help maestro --version - name: Boot Simulator run: | xcrun simctl list runtimes export RUNTIME="iOS18.5" export DEVICE_TYPE="iPhone 16" ./.github/scripts/boot_simulator.sh - name: Download apps working-directory: ${{ github.workspace }}/e2e run: ./download_apps ios - name: Install apps working-directory: ${{ github.workspace }}/e2e run: ./install_apps ios - name: Start screen recording run: | xcrun simctl io booted recordVideo --codec h264 ~/screenrecord.mp4 & echo $! > ~/screenrecord.pid - name: Run tests working-directory: ${{ github.workspace }}/e2e timeout-minutes: 120 run: ./run_tests ios - name: Stop screen recording if: success() || failure() run: kill -SIGINT "$(cat ~/screenrecord.pid)" - name: Upload ~/.maestro artifacts uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-root-dir-ios path: ~/.maestro retention-days: 7 include-hidden-files: true - name: Upload xctest runner logs uses: actions/upload-artifact@v6 if: success() || failure() with: name: xctest_runner_logs path: ~/Library/Logs/maestro/xctest_runner_logs retention-days: 7 include-hidden-files: true - name: Upload screen recording of Simulator uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-screenrecord-ios.mp4 path: ~/screenrecord.mp4 retention-days: 7 ================================================ FILE: .github/workflows/test-e2e-prod.yaml ================================================ name: Test E2E (prod) on: workflow_dispatch: schedule: - cron: '0 * * * *' jobs: test-cloud-production: # This job is copied from "e2e-production" in mobile-dev-inc/monorepo. # We want it here so open-source users can also have some visibility into it. runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - name: Clone repository uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - name: Install Maestro run: | curl -Ls --retry 3 --retry-all-errors "https://get.maestro.mobile.dev" | bash echo "${HOME}/.maestro/bin" >> $GITHUB_PATH - name: Print Maestro version run: maestro --version - name: Download samples run: maestro download-samples - name: Run iOS test run: | maestro cloud \ --apiKey ${{ secrets.E2E_MOBILE_DEV_API_KEY }} \ --timeout 180 \ --fail-on-cancellation \ --include-tags=advanced \ samples/sample.zip samples - name: Run Android test run: | maestro cloud \ --apiKey ${{ secrets.E2E_MOBILE_DEV_API_KEY }} \ --fail-on-cancellation \ --include-tags advanced \ samples/sample.apk samples - name: Send Slack message if: failure() run: | curl --request POST \ --url "${{ secrets.E2E_SLACK_WEBHOOK_URL }}" \ --header 'Content-Type: application/json' \ --data '{ "text": "🚨 *Maestro E2E Test Failed*\nStatus: 'Failure'\nRun: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View details>" }' # - name: Trigger alert on failure # if: ${{ false }} # # if: failure() # run: | # curl --request POST \ # --url "https://events.pagerduty.com/v2/enqueue" \ # --header 'Content-Type: application/json' \ # --data '{ # "payload": { # "summary": "E2E test failed", # "source": "E2E test", # "severity": "critical" # }, # "routing_key": "${{ secrets.E2E_PAGER_DUTY_INTEGRATION_KEY }}", # "event_action": "trigger", # "links": [ # { # "href": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", # "text": "Failed E2E test - Github Action" # } # ] # }' ================================================ FILE: .github/workflows/test-e2e.yaml ================================================ name: Test E2E on: workflow_dispatch: pull_request: jobs: build: name: Build on Java ${{ matrix.java-version }} runs-on: macos-latest timeout-minutes: 20 strategy: fail-fast: false matrix: java-version: [17] steps: - name: Clone repository uses: actions/checkout@v6 - name: Set up Java uses: actions/setup-java@v5 with: distribution: zulu java-version: ${{ matrix.java-version }} cache: gradle - name: Build xctest-runner run: ./maestro-ios-xctest-runner/build-maestro-ios-runner.sh | xcbeautify - name: Build Maestro CLI run: ./gradlew :maestro-cli:distZip - name: Upload zipped Maestro CLI artifact uses: actions/upload-artifact@v6 with: name: maestro-cli-jdk${{ matrix.java-version }}-run_id${{ github.run_id }} path: maestro-cli/build/distributions/maestro.zip retention-days: 1 - name: Upload build/Products to artifacts uses: actions/upload-artifact@v6 with: name: build__Products-jdk${{ matrix.java-version }} path: build/Products retention-days: 1 test-web: name: Test on Web runs-on: ubuntu-latest needs: build steps: - name: Clone repository (only needed for the e2e directory) uses: actions/checkout@v6 - name: Set up demo_app workspace run: | git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/ - name: Set up Java uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 - name: Set up Chrome uses: browser-actions/setup-chrome@v2 with: chrome-version: 142 - name: Download artifacts uses: actions/download-artifact@v7 with: name: maestro-cli-jdk17-run_id${{ github.run_id }} - name: Add Maestro CLI executable to PATH run: | unzip maestro.zip -d maestro_extracted echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH - name: Check if Maestro CLI executable starts up run: | maestro --help maestro --version - name: Run tests working-directory: ${{ github.workspace }}/e2e timeout-minutes: 20 run: ./run_tests web - name: Upload ~/.maestro artifacts uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-root-dir-web path: ~/.maestro retention-days: 7 include-hidden-files: true test-android: name: Test on Android runs-on: ubuntu-latest needs: build timeout-minutes: 60 env: ANDROID_HOME: /home/runner/androidsdk ANDROID_SDK_ROOT: /home/runner/androidsdk ANDROID_AVD_HOME: /home/runner/.config/.android/avd/ ANDROID_OS_IMAGE: system-images;android-32;google_apis;x86_64 ANDROID_PLATFORM: platforms;android-34 steps: - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Clone repository (only needed for the e2e directory) uses: actions/checkout@v6 - name: Set up demo_app workspace run: | git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/ - name: Set up Java uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 - name: Download Maestro build from previous job uses: actions/download-artifact@v7 with: name: maestro-cli-jdk17-run_id${{ github.run_id }} - name: Add Maestro CLI executable to PATH run: | unzip maestro.zip -d maestro_extracted echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH - name: Check if Maestro CLI executable starts up run: | maestro --help maestro --version - name: Set up mobile-dev-inc/bartek-scripts (for install_android_sdk script) run: | git clone https://github.com/mobile-dev-inc/bartek-scripts.git $HOME/scripts echo "$HOME/scripts/bin" >> $GITHUB_PATH - name: Set up Android Command-line Tools run: | # v13 - see https://stackoverflow.com/a/78890086/7009800 install_android_sdk https://dl.google.com/android/repository/commandlinetools-linux-12266719_latest.zip echo "$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" >> $GITHUB_PATH - name: Set up Android SDK components run: | yes | sdkmanager --licenses sdkmanager --install emulator echo "$ANDROID_HOME/emulator" >> $GITHUB_PATH sdkmanager --install "platform-tools" echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH sdkmanager --install "$ANDROID_PLATFORM" sdkmanager --install "$ANDROID_OS_IMAGE" - name: Create AVD run: | avdmanager -s create avd \ --package "$ANDROID_OS_IMAGE" \ --name "MyAVD" echo "DEBUG INFO" avdmanager list avd echo "ANDROID_PREFS_ROOT=$ANDROID_PREFS_ROOT" echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" echo "ANDROID_HOME=$ANDROID_HOME" echo "ANDROID_SDK_HOME=$ANDROID_SDK_HOME" echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" echo "ANDROID_EMULATOR_HOME=$ANDROID_EMULATOR_HOME" echo "HOME=$HOME" cat << EOF >> ~/.config/.android/avd/MyAVD.avd/config.ini hw.cpu.ncore=2 hw.gpu.enabled=yes hw.gpu.mode=swiftshader_indirect hw.ramSize=3072 disk.dataPartition.size=4G vm.heapSize=576 hw.lcd.density=440 hw.lcd.height=2220 hw.lcd.width=1080 EOF - name: Run AVD run: | emulator @MyAVD \ -verbose -no-snapshot -no-window -no-audio -no-boot-anim -accel on -camera-back none -qemu -m 3072 \ >~/emulator_stdout.log \ 2>~/emulator_stderr.log & - name: Wait for AVD to start up run: | adb wait-for-device && echo 'Emulator device online' adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' && echo 'Emulator booted' # This is also a prerequiste while true; do adb shell service list | grep 'package' && echo 'service "package" is active!' && break echo 'waiting for service "package" to start' sleep 1 done - name: Download apps working-directory: ${{ github.workspace }}/e2e run: ./download_apps android - name: Install apps working-directory: ${{ github.workspace }}/e2e run: ./install_apps android - name: Start screen recording of AVD run: | adb shell screenrecord /sdcard/screenrecord.mp4 & echo $! > ~/screenrecord.pid - name: Run tests working-directory: ${{ github.workspace }}/e2e timeout-minutes: 20 run: ./run_tests android - name: Stop screen recording of AVD if: success() || failure() run: | kill -SIGINT "$(cat ~/screenrecord.pid)" || echo "failed to kill screenrecord: code $?" && exit 0 sleep 5 # prevent video file corruption adb pull /sdcard/screenrecord.mp4 ~/screenrecord.mp4 - name: Upload ~/.maestro artifacts uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-root-dir-android path: ~/.maestro retention-days: 7 include-hidden-files: true - name: Upload screen recording of AVD uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-screenrecord-android.mp4 path: ~/screenrecord.mp4 retention-days: 7 test-ios: name: Test on iOS runs-on: macos-26 needs: build timeout-minutes: 120 env: MAESTRO_DRIVER_STARTUP_TIMEOUT: 240000 # 240s MAESTRO_CLI_LOG_PATTERN_CONSOLE: '%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n' steps: - name: Clone repository (only needed for the e2e directory) uses: actions/checkout@v6 - name: Set up demo_app workspace run: | git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/ - name: Set up JDK uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 - name: Download artifacts uses: actions/download-artifact@v7 with: name: maestro-cli-jdk17-run_id${{ github.run_id }} - name: Add Maestro CLI executable to PATH run: | unzip maestro.zip -d maestro_extracted echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH - name: Check if Maestro CLI executable starts up run: | maestro --help maestro --version - name: Boot Simulator run: | xcrun simctl list runtimes export RUNTIME="iOS26.1" export DEVICE_TYPE="iPhone 17 Pro" ./.github/scripts/boot_simulator.sh - name: Download apps working-directory: ${{ github.workspace }}/e2e run: ./download_apps ios - name: Install apps working-directory: ${{ github.workspace }}/e2e run: ./install_apps ios - name: Start screen recording run: | xcrun simctl io booted recordVideo --codec h264 ~/screenrecord.mp4 & echo $! > ~/screenrecord.pid - name: Run tests working-directory: ${{ github.workspace }}/e2e timeout-minutes: 120 run: ./run_tests ios - name: Stop screen recording if: success() || failure() run: kill -SIGINT "$(cat ~/screenrecord.pid)" - name: Upload ~/.maestro artifacts uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-root-dir-ios path: ~/.maestro retention-days: 7 include-hidden-files: true - name: Upload xctest runner logs uses: actions/upload-artifact@v6 if: success() || failure() with: name: xctest_runner_logs path: ~/Library/Logs/maestro/xctest_runner_logs retention-days: 7 include-hidden-files: true - name: Upload screen recording of Simulator uses: actions/upload-artifact@v6 if: success() || failure() with: name: maestro-screenrecord-ios.mp4 path: ~/screenrecord.mp4 retention-days: 7 test-ios-xctest-runner: name: Test on iOS (XCTest Runner only) if: false # Disabled: This needs be fixed, not working yet. runs-on: macos-latest needs: build timeout-minutes: 30 steps: - name: Clone repository (only needed for the e2e directory) uses: actions/checkout@v6 - name: Set up JDK uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 - name: Download Maestro artifact uses: actions/download-artifact@v7 with: name: maestro-cli-jdk17-run_id${{ github.run_id }} - name: Download build/Products artifact uses: actions/download-artifact@v7 with: name: build__Products-jdk17 path: build/Products - name: Add Maestro CLI executable to PATH run: | unzip maestro.zip -d maestro_extracted echo "$PWD/maestro_extracted/maestro/bin" >> $GITHUB_PATH - name: Check if Maestro CLI executable starts up run: | maestro --help maestro --version - name: Boot Simulator run: ./.github/scripts/boot_simulator.sh - name: Run tests timeout-minutes: 15 run: ./maestro-ios-xctest-runner/test-maestro-ios-runner.sh - name: Upload xc test runner logs uses: actions/upload-artifact@v6 if: success() || failure() with: name: test-ios-xctest-runner__xctest_runner_logs path: ~/Library/Logs/maestro/xctest_runner_logs retention-days: 7 include-hidden-files: true ================================================ FILE: .github/workflows/test.yaml ================================================ name: Test on: workflow_dispatch: pull_request: jobs: unit-test: name: Unit Test on Java ${{ matrix.java-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: java-version: [17] steps: - name: Clone repository uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: zulu java-version: ${{ matrix.java-version }} cache: gradle - name: Test id: unit-test run: ./gradlew test - name: Upload unit test report uses: actions/upload-artifact@v4 if: failure() with: name: maestro-unit-test-report path: ./**/build/reports/tests/test retention-days: 1 include-hidden-files: true ios-driver-lib-test: name: MaestroDriverLib Unit Tests runs-on: macos-latest timeout-minutes: 10 steps: - name: Clone repository uses: actions/checkout@v4 - name: Run MaestroDriverLib Tests working-directory: ${{ github.workspace }}/maestro-ios-xctest-runner/MaestroDriverLib run: swift test ios-xctest-runner-test: name: iOS XCTest Runner Unit Tests runs-on: macos-latest timeout-minutes: 15 steps: - name: Clone repository uses: actions/checkout@v4 - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer - name: Run iOS Unit Tests working-directory: ${{ github.workspace }}/maestro-ios-xctest-runner run: | xcodebuild test \ -project maestro-driver-ios.xcodeproj \ -scheme maestro-driver-iosTests \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -only-testing:maestro-driver-iosTests \ | xcpretty --color || exit ${PIPESTATUS[0]} validate-gradle-wrapper: runs-on: ubuntu-latest steps: - name: Clone repository uses: actions/checkout@v4 - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v4 ================================================ FILE: .github/workflows/update-samples.yaml ================================================ name: Update samples on: workflow_dispatch: push: branches: [main] jobs: main: runs-on: ubuntu-latest if: github.repository == 'mobile-dev-inc/maestro' steps: - name: Clone repository uses: actions/checkout@v4 - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 with: # These credentials should only have write access to the bucket credentials_json: ${{ secrets.GCP_MOBILEDEV_BUCKET_CREDENTIALS }} - name: Set up Google Cloud CLI uses: google-github-actions/setup-gcloud@v2 with: version: '>= 484.0.0' project_id: perf-dev-289002 - name: Upload samples to public Google Cloud Storage bucket run: | cd e2e/ ./update_samples ================================================ FILE: .gitignore ================================================ .DS_Store # Ignore Gradle project-specific cache directory .gradle # Ignore Gradle build output directory build # Ignore Gradle local properties local.properties bin # media assets maestro-orchestra/src/test/resources/media/assets/* # Local files local/ ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml # Editor-based HTTP Client requests /httpRequests/ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml # Above is default, IntelliJ-generated config. Below is our custom config. # Inspired by: # - https://github.com/Vadorequest/JetBrains-Intellij-IDEA-.gitignore-best-practices # - https://github.com/salarmehr/idea-gitignore # - https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore dataSources/ dataSources.local.xml misc.xml workspace.xml google-java-format.xml inspectionProfiles/ compiler.xml deploymentTargetSelector.xml gradle.xml kotlinc.xml vcs.xml copilot.data.migration.* ================================================ FILE: .idea/.name ================================================ maestro ================================================ FILE: .idea/dictionaries/project.xml ================================================ addmedia amanjeet applesimutils avdmanager bartekpacia bartkepacia berik caseley cirrusci clearstate dadb devicectl dpad evals faceid feeditem graal graaljs inputmethod iphoneos iphonesimulator jreleaser keyevent macosx mdev medialibrary mobiledev mobilesafari modelcontextprotocol niklasson nowinandroid openqa posthog printenv reinstalls rhinojs runscript saveliev screenrecord screenrecording sdkmanager simctl systemui takamine testsuites tokou udid visschers xcbeautify xcrun xctestrun xctestrunner xctrunner xcuitest yamls zaytsev ================================================ FILE: .run/cli-version.run.xml ================================================ ================================================ FILE: .run/cli.run.xml ================================================ ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Unreleased ## 2.4.0 - Add new device config flags for cloud and start-device - Deprecated `--ios-version`, `--android-api-level`, and `--os-version` flags. These will be removed in a future release. - `--device-os` and `--device-model` replace all platform-specific device options, providing a single consistent way to specify devices across iOS, Android, and Web - Add `maestro list-devices` command to see locally available devices on the machine - Add `maestro list-cloud-devices` command to see available cloud device models and OS versions - Support iframes for web tests - Faster feedback when using Maestro Cloud - more validation is happening locally - Improve feedback when startRecording fails on iOS - Add clearState support for web tests - Fix inputText crashing on iOS pincode screens - Fix incorrect websocket timeout (5000ms, not 5000s!) - Add support for variables as input to setOrientation - Add deprecation notice to `maestro studio` - Improved variable isolation and reduced memory usage in JavaScript evaluations - Fix step ordering in the html-detailed test report - Improve timeouts in all API calls (especially useful for Maestro Cloud uploads) ## 2.3.0 - Add web support for `clearState` command - Fix `assertScreenshot` not failing when screenshot dimensions are mismatched - Make `assertScreenshot` work more like `takeScreenshot` by not requiring file extension - Fix path resolution for `assertScreenshot` to allow relative paths from flows for reference images - Fix `inputRandomPersonName` to generate a predictable "FirstName LastName" format - Fix iPad landscape orientation support - Fix specifying `--device` when also specifying `--host` - Fix cloud uploads to always use requested device specifications on retries Thanks to @SosenWiosen, @leggomuhgreggo and @jkronborg who contributed changes included in this release ❤️ ## 2.2.0 - Add `--screen-size` option to test command, to specify the headless browser window size when testing web flows - Add `MAESTRO_DEVICE_UDID`, `MAESTRO_SHARD_ID`, and `MAESTRO_SHARD_INDEX` as default environment variables (useful for screenshot filenames when sharding) - Add step information to HTML test reports via a new `html-detailed` formatter - Add tags and custom property information to HTML and JUnit test reports - Add a new `assertScreenshot` command for visual regression testing - Add a `cropOn` property to the `takeScreenshot` command to crop screenshots to a specific element - Fix scrolling in Flutter Web - Fix output of subflows when using `--no-ansi` flag - Show `maestro hierarchy` and `maestro check-syntax` commands in `maestro --help` - Fix iOS driver app on Simulators running on Intel-based Macs - Fix a potential hang between Maestro and the on-device drivers when calls take too long - Some logging adjustments for less noise during web tests - Bump web support to Chrome v144 - Bump DataFaker to v2.5.3, GraalJS engine to v24.2.0, log4j to v2.25.3 Thanks to @sazquatch17, @ImL1s, @sidferreira, @SosenWiosen, @TheKohan, @Fl0p, @ff-vivek and @eldare who all contributed changes included in this release ❤️ ## 2.1.0 - Add `setPermissions` command, for setting app permissions outside of `launchApp` - Add `setClipboard` command, for setting Maestro's internal clipboard without copying from an element - Add `--platform` and `--device` to `maestro test` command - Add custom JUnit properties to reporting - Add support for --no-reinstall-driver option to `test` and `hierarchy` commands - Add creation of missing folders specified in the path when taking screenshots or recording videos - Bump web support to Chrome v142 - Bump npm dependencies in legacy Maestro Studio - Hide incomplete `maestro driver-setup` command from `maestro --help` - Remove deprecated `deterministicOrder` feature from workspace config - Remove deprecated `maestro upload` command - Fix bug that reported that analytics was enabled when it wasn't - Fix building Maestro on Java >17 - Fix link in `maestro bugreport` - Fix cancellation of flows whilst repeat loops are running - Fix enumeration of multi-select elements in Web - Fix use of hierarchy and screenshot strategies across Android and iOS - Fix web tests running into Chrome's password leak detection - Fix webview detection and interaction on iOS 26 - Fix broken relative paths when uploading files via multipart form in `http.post` Special thanks to the Maestro community for contributing to this release! Shout out to @tokou, @kprakash2, @trongrg, @vibin, @ryuuhei0729, @Thomvis, @MarcellDr and @leovarmak ❤️ ## 2.0.10 - Fix error messaging when running with shards fails - Improve gathering of dependencies when running single flows with `maestro cloud` ## 2.0.7 ### Fix - Fixed bug affecting CI and pull request integrations where org prompts would fail in non-interactive environments. ## 2.0.6 ### Features - Added support for negative index in element selector - Made specifying `--project_id` for cloud upload optional. **In case it is not specified and there are multiple projects, a prompt for selecting the project will be provided.** - In case the user belongs to multiple organizations and hasn't specified `--api-key` during cloud upload, a prompt for **selecting the organization** will be provided. ### Fixes - Added descriptions to missing element selector aspects (enabled, disabled, selected, not selected, focused, not focused) ## 2.0.5 ### Fixes - Removed debugging logs ## 2.0.4 ### Features - Added support for tapping at specific coordinates relative to an element using the `relativePoint` parameter in `tapOn` and `doubleTapOn` commands [Github Issue](https://github.com/mobile-dev-inc/Maestro/issues/2059) - Labels in commands can now be dynamically evaluated using JavaScript expressions (thanks @jerriais!) ### Fixes - Fixed issue where `maestro login` would fail if user was already logged in - Fixed iOS permission setting when using 'all' with specific permission overrides - Fixed issue where platform argument would be ignored - Fixed issue where blank platform argument would incorrectly filter out all tests - Fixed off-by-one error when specifying count with `eraseText` command on Android - Improved performance by evaluating script conditions eagerly, ahead of visibility conditions (thanks @tokou!) - Fixed crash when running Maestro with empty arguments - Updated iOS test runner to support Xcode 26 - Improved logging on Android driver timeouts - Improved copy/paste experience in legacy Maestro Studio (thanks @tylerqr!) ## 2.0.3 Fixes: - Fix filter logic that was causing incorrect element selection when using multiple selectors together for some applications - GraalJS will now isolate environment variables correctly between different runScript executions - Fix incorrect reporting of failures in `HtmlTestSuiteReporter` ## 2.0.2 Fixes: - Added Rhino deprecation warning in CLI - Fix conditions for checking if web flows exist in workspace - Added back Run details to cloud upload logs (regression in Maestro 2.0.0) ## 2.0.1 Fixes: - Fix issues with launching CLI on Windows systems ## 2.0.0 Breaking Change: - Updated java version to 17 better performance, security, and modern features. **If you’re still on an older version, update before using 2.0.0.** - We’ve switched from Rhino to **GraalJS** as the default JavaScript engine. Expect **faster execution** and **modern JS support** for your scripts. [GraalJS Behaviour Differences](https://docs.maestro.dev/advanced/javascript/graaljs-support#graaljs-behavior-differences) - URLs in the `appId` field are no longer supported. Flows must now use the `url` field in the YAML config for URLs. Features: - Added `setOrientation` command — adjust device orientation in tests (`PORTRAIT`, `UPSIDE_DOWN`, `LANDSCAPE_LEFT`, `LANDSCAPE_RIGHT`). ([Docs](https://docs.maestro.dev/api-reference/commands/setorientation)) - Enhanced MCP Integration: - More accurate flow path resolution - View hierarchy output size reduced by **50%** (faster & lighter) - `run_flow` / `run_flow_files` now support env variables & hooks - Added `--test-output-dir` to specify where test artifacts should be saved. ([Docs](https://docs.maestro.dev/cli/test-output-directory)) - Added support for running entire workspace of **web flows** in a single `test` command. - Allowed Keep-Alive from Server to support for persistent connections. - Environment variables are now isolated between peer `runFlow` commands. - Added timestamp to JUnit and HTML test report - DataFaker is now available in JavaScript to generate random data for use in tests ([Docs](https://docs.maestro.dev/advanced/javascript/generating-random-with-faker.md)) Fixes: - Fix CLI Cloud upload output - Fix broken `maestro studio` command for web version of Studio. - Fix **memory leak** for ios test runs that could cause out of memory issues on testing environments. - Fix `maestro cloud` command when uploading files that have external dependencias (subflows, scripts and media) - Fix disconnect in local iOS test executions when flow contains a large element tree ## 1.41.0 Fix: - Resolved an issue where view hierarchy was incorrectly returned on full-screen apps or larger devices (e.g., iPhone Pro models, iOS 18). This affected selector matching for taps and assertions. - Maestro now properly handles timeouts from the XCTest framework when the app UI is slow or too large. These are surfaced as actionable exceptions with helpful messages. - setLocation now mocks all major location providers (GPS, network, fused). Also ensures proper cleanup when the driver shuts down. - Errors when .maestro config file is misinterpreted as a test flow file. Features: - Platform configs are now supported via workspace configuration [(Docs)](https://docs.maestro.dev/api-reference/configuration/workspace-configuration#platform-configuration): * `disableAnimations` for both android and iOS. * `snapshotKeyHonorModalViews`: On iOS, includes elements behind modals that are still visible on modal to user but gets missing in hierarchy. - Added support for selecting `select` tags dropdown elements in web flows. - Debug messages are now attached to Maestro exceptions to help users understand failures faster. - Added support for selecting elements using CSS/DOM query - Added Maestro MCP server implementation to cli by [[Stevie Clifton](https://github.com/steviec)] Breaking Change: - `retryTapIfNoChange` is now disabled by default. It was causing side effects in some apps. If needed, it can still be manually enabled. ## 1.40.3 Fix - MissingKotlinParameterException during using maestro commands. ## 1.40.2 Fix - Sharding on iOS, throwing FileSystemAlreadyExistsException exception ## 1.40.1 Fix - iOS apps going on background while using maestro commands Feature - Flag to skip interactive device selection by picking a --device-index ## 1.40.0 Fix: - JavaScript fails when running maestro test in continuous mode. Affected Commands: `maestro test`, `maestro record` ([#2311](https://github.com/mobile-dev-inc/Maestro/pull/2311)) - Ignore notifications in analyse command for CI ([#2306](https://github.com/mobile-dev-inc/Maestro/pull/2306)) - `config.yaml` not resolving on Windows ([#2327](https://github.com/mobile-dev-inc/Maestro/pull/2327)) - Fix swipe command failure on iOS after upgrading to Xcode 16.2 [issue #2422](https://github.com/mobile-dev-inc/maestro/issues/2422) ([#2332](https://github.com/mobile-dev-inc/maestro/pull/2332)) - Fix `app-binary-id` option on maestro cloud upload ([#2361](https://github.com/mobile-dev-inc/Maestro/pull/2361)) - Ensure commands with missing elements fail as expected in Studio ([#2140](https://github.com/mobile-dev-inc/Maestro/pull/2140)) - Prevent flows from getting stuck on the cloud by properly setting driver closing state ([#2364](https://github.com/mobile-dev-inc/Maestro/pull/2364)) - Fix `maestro cloud` & `maestro start-device` on windows ([#2371](https://github.com/mobile-dev-inc/Maestro/pull/2371)) - Improved `maestro cloud` to only process valid flow `.yaml`/`.yml` files and skip unrelated files like `config.yaml`, preventing parsing errors when uploading folders with mixed content ([#2359](https://github.com/mobile-dev-inc/Maestro/pull/2359)) - Improved `maestro cloud` to skip validating non-flow files (e.g., .js, README, config.yaml) in folders, preventing parsing errors and upload failures - Fix setting up iOS Driver when not on bash environment ([#2412](https://github.com/mobile-dev-inc/Maestro/pull/2412)) - Speed up view hierarchy generation by reducing SpringBoard queries and avoiding redundant app list calls on iOS. ([#2419](https://github.com/mobile-dev-inc/Maestro/pull/2419)) Features: - Added support for `androidWebViewHierarchy: devtools` option to build Android WebView hierarchy using Chrome DevTools ([#2350](https://github.com/mobile-dev-inc/Maestro/pull/2350)) - Added Chrome to available devices for web automation ([#2360](https://github.com/mobile-dev-inc/Maestro/pull/2360)) - Introduced pre-built mode for setting up iOS driver on simulators without relying on `xcodebuild` ([#2325](https://github.com/mobile-dev-inc/Maestro/pull/2325)) - Added command-line chat mode to Maestro CLI accessible by `maestro chat --ask=` and `maestro chat` ([#2378](https://github.com/mobile-dev-inc/Maestro/pull/2378)) - Introduced `maestro check-syntax` command for validating flow syntax ([#2387](https://github.com/mobile-dev-inc/Maestro/pull/2387)) - Added `--reinstall-driver` flag that reinstalls xctestrunner driver before running the test. Set to false if the driver shouldn't be reinstalled ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413)) - Added `--compact` flag that remove empty values to make the output hierarchy json smaller ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413)) - Added `--device-os` and `--device-model` options to target specific iOS minor versions and devices ([Docs](https://docs.maestro.dev/cloud/reference/configuring-os-version#using-a-specific-ios-minor-version-and-device-recommended)) ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413)) - Added support for ios 18 on cloud and local - Bumped default iOS version to 16 for `maestro start-device` - Enabled AI command usage on `mobile.dev` ([#2425](https://github.com/mobile-dev-inc/Maestro/pull/2425)) Chore: - Update Flying Fox HTTP server on iOS driver ([#2415](https://github.com/mobile-dev-inc/Maestro/pull/2415)) - Migrated app termination from `simctl` to `xctest` for improved stability` ([#2418](https://github.com/mobile-dev-inc/Maestro/pull/2418)) ## 1.39.13 - Fix : Adding upload route back again - Feature: Removing Analyze logs from CI uploads ## 1.39.12 - Fix: Upload route on Robin was not working on maestro cloud command ## 1.39.11 - Feature: Starting trial from CLI - Feature: Better logs to improve visibility - Feature: Prebuilt iOS driver without xcodebuild - Feature: Analyze option to test command ## 1.39.10 - Update install script to tidy up old installation binaries ## 1.39.9 - Revert: Error in showing keyboard during input and erase commands on iOS - Fix: applesimutils affecting granting location permission - Fix: Setting host and port from the optional arguments - Feature: New `maestro login` command for logging in Robin. - Feature: Improved `maestro record` video to scroll and follow the currently executing commands - Fix: Enable running Maestro on Windows without WSL - Feature: Add console.log messages directly to the maestro log file. ## 1.39.8 - Fix: Debug message not showing up when we execute commands on maestro cli anymore ## 1.39.7 - Feature: Improved web support. - Fix: Maestro can test web pages again (it was broken) - Fix: WebDriver was reporting invalid screen size - Web: support cases where a new tab is opened from the page - Web: screen recording support (via JCodec for now, but we could add ffmpeg later) - Web: fake geolocation support - Studio: better layout for wide aspect-ratio screens (i.e. web pages or tablets) - Feature: Introduces extractTextWithAI command - Fix: Retry should throw exception when max retries reaches - Fix: Studio getting unresponsive due to exceptions in streaming device ## 1.39.5 Released on 2024-12-16 Fixes: - Fix: Failure on how the assertConditionCommand was being handled on Robin([#2171](https://github.com/mobile-dev-inc/maestro/pull/2171)) ## 1.39.4 Features: - Add `waitToSettleTimeoutMs` to other swipe related commands ([#2153](https://github.com/mobile-dev-inc/maestro/pull/2153)) - Add retry command for flaky conditions ([#2168](https://github.com/mobile-dev-inc/maestro/pull/2168)) - Add support for recording maestro flows locally instead of using remote servers ([#2173](https://github.com/mobile-dev-inc/maestro/pull/2173)) Fixes: - Fix: multiple xcodebuild process and leading to IOSDriverTimeoutException ([#2097](https://github.com/mobile-dev-inc/maestro/pull/2097)) - Fix: NullPointerException during view hierarchy operations for android ([#2172](https://github.com/mobile-dev-inc/maestro/pull/2172)) - Fix: Debug level logs in maestro.log file leading to large debug files ([#2170](https://github.com/mobile-dev-inc/maestro/pull/2170)) - Fix: Environment variable not being set for test suite ([#2163](https://github.com/mobile-dev-inc/maestro/pull/2163)) - Fix: Failures on clearKeychain operations on iOS due to missing directories ([#2178](https://github.com/mobile-dev-inc/maestro/pull/2178)) ## 1.39.2 Released on 2024-11-19 Fixes: - Fix: Insights object causing ConcurrentModificationException ([#2131](https://github.com/mobile-dev-inc/maestro/pull/2131)) - Fix: Timeout unit in scrollUntilVisible command ([#2112](https://github.com/mobile-dev-inc/maestro/pull/2112)) - Feat: Add new status for robin flows: PREPARING and INSTALLING. ([#2145](https://github.com/mobile-dev-inc/maestro/pull/2145)) ## 1.39.1 Released on 2024-11-04 Fixes: - Fix: clearState now automatically reinstall the App ([#2118](https://github.com/mobile-dev-inc/maestro/pull/2118)) ## 1.39.0 Released on 2024-10-15 Features: - Feature: add `--shard-split` and `--shard-all` options to `maestro test` ([#1955](https://github.com/mobile-dev-inc/maestro/pull/1955) by [Tarek Belkahia](https://github.com/tokou)) The `--shard` is now deprecated and superseded by `--shard-split`. - Feature: allow for passing multiple flow files to `maestro test` ([#1995](https://github.com/mobile-dev-inc/maestro/pull/1995) by [Tarek Belkahia](https://github.com/tokou)) - Feature: add the `optional` argument to all commands ([#1946](https://github.com/mobile-dev-inc/maestro/pull/1946) by [Tarek Belkahia](https://github.com/tokou)) This new command-level `optional` argument supersedes the (now removed) selector-level `optional` argument. No behavior changes are expected. When command with `optional: true` fails, its status is now "warned ⚠️" instead of "skipped ⚪️" - Feature: add changelog to the update prompt when new Maestro version is available ([#1950](https://github.com/mobile-dev-inc/maestro/pull/1950) by [Tarek Belkahia](https://github.com/tokou)) - Feature: add back the `--platform` option ([#1954](https://github.com/mobile-dev-inc/maestro/pull/1954) by [Tarek Belkahia](https://github.com/tokou)) - Feature: expose current flow name as `MAESTRO_FILENAME` env var ([#1945](https://github.com/mobile-dev-inc/maestro/pull/1945) by [Tarek Belkahia](https://github.com/tokou)) Fixes: - Fix: Warnings generated by AI-powered commands aren't formatted nicely ([#2043](https://github.com/mobile-dev-inc/maestro/pull/2043)) ([#2044](https://github.com/mobile-dev-inc/maestro/pull/2044)) - Fix: not working when iOS simulator is in landscape orientation ([caveats apply](https://github.com/mobile-dev-inc/maestro/pull/1974#issuecomment-2346074593)) ([#1974](https://github.com/mobile-dev-inc/maestro/pull/1974)) - Fix: confusing error message "BlockingCoroutine is cancelling" ([#2036](https://github.com/mobile-dev-inc/maestro/pull/2036)) - Fix: AI-powered commands crashing when Anthropic is used ([#2033](https://github.com/mobile-dev-inc/maestro/pull/2033)) - Fix: display warnings generated by AI-powered commands in CLI output when `optional: true` ([#2026](https://github.com/mobile-dev-inc/maestro/pull/2026)) - Fix: visual bug with emojis having slightly different length in `maestro test`'s interactive CLI output ([#2016](https://github.com/mobile-dev-inc/maestro/pull/2016)) - Fix: no tests being run when flowsOrder specified all tests in the workspace ([#2003](https://github.com/mobile-dev-inc/maestro/pull/2003)) - Fix: using integers from JavaScript outputs causing a deserialization error ([#1788](https://github.com/mobile-dev-inc/maestro/pull/1788) by [Muhammed Furkan Boran](https://github.com/boranfrkn)) - Fix: delete temporary APKs after using them ([#1947](https://github.com/mobile-dev-inc/maestro/pull/1947) by [Tarek Belkahia](https://github.com/tokou)) - Fix: allow env vars in `setLocation` and `travel` commands ([#1988](https://github.com/mobile-dev-inc/maestro/pull/1988) by [Prasanta Biswas](https://github.com/prasanta-biswas)) - Fix: error message when specifying `--format` together with `--continuous` #1948 ([#1948](https://github.com/mobile-dev-inc/maestro/pull/1948) by [Tarek Belkahia](https://github.com/tokou)) Chores: - Chore: clean up logging, make log format configurable with 2 new env vars ([#2041](https://github.com/mobile-dev-inc/maestro/pull/2041)) - Chore: make Maestro build & compile on Java 17 ([#2008](https://github.com/mobile-dev-inc/maestro/pull/2008)) - Chore: Migrate all Gradle buildscripts to Gradle Kotlin DSL ([#1994](https://github.com/mobile-dev-inc/maestro/pull/1994)) ## 1.38.1 Released on 2024-08-30 - New experimental AI-powered commands for screenshot testing: [assertWithAI](https://maestro.mobile.dev/api-reference/commands/assertwithai) and [assertNoDefectsWithAI](https://maestro.mobile.dev/api-reference/commands/assertnodefectswithai) ([#1906](https://github.com/mobile-dev-inc/maestro/pull/1906)) - Enable basic support for Maestro uploads while keeping Maestro Cloud functioning ([#1970](https://github.com/mobile-dev-inc/maestro/pull/1970)) ## 1.37.9 Released on 2024-08-15 - Revert iOS landscape mode fix ([#1916](https://github.com/mobile-dev-inc/maestro/pull/1916)) ## 1.37.8 Released on 2024-08-14 - Fix sharding on Android failing on all but one devices (quick hotfix) ([#1867](https://github.com/mobile-dev-inc/maestro/pull/1867)) - Fix CLI crash when flow is canceled on Maestro Cloud ([#1912](https://github.com/mobile-dev-inc/maestro/pull/1912)) - Fix iOS landscape mode ([caveats apply](https://github.com/mobile-dev-inc/maestro/pull/1809#issuecomment-2249917209)) ([#1809](https://github.com/mobile-dev-inc/maestro/pull/1809)) - Skip search engine selection when running on the web ([#1869](https://github.com/mobile-dev-inc/maestro/pull/1869)) ## 1.37.7 Released on 2024-08-03 - Fix cryptic "Socket Exception" when `CI` env var is set, once and for all ([#1882](https://github.com/mobile-dev-inc/maestro/pull/1882)) ## 1.37.6 Released on 2024-08-02 - Print stack trace on 3rd retry ([#1877](https://github.com/mobile-dev-inc/maestro/pull/1877)) ## 1.37.5 Released on 2024-08-02 - Fix cryptic "SocketException" when API token is invalid ([#1871](https://github.com/mobile-dev-inc/maestro/pull/1871)) ## 1.37.4 Released on 2024-07-30 - Don't ask for analytics permission on CI + add `MAESTRO_CLI_NO_ANALYTICS` env var ([#1848](https://github.com/mobile-dev-inc/maestro/pull/1848)) ## 1.37.3 Released on 2024-07-29 ### Bug fixes - Fix `FileNotFoundException: ~.maestro/sessions` ([#1843](https://github.com/mobile-dev-inc/maestro/pull/1843)) ## 1.37.2 - 2024-07-29 ### Bug fixes - Fix `UnsupportedOperationException: Empty collection can't be reduced` ([#1840](https://github.com/mobile-dev-inc/maestro/pull/1840)) ## 1.37.1 - 2024-07-29 ### Bug fixes - Fix crash when `flutter` or `xcodebuild` is not installed ([#1839](https://github.com/mobile-dev-inc/maestro/pull/1839)) ## 1.37.0 - 2024-07-29 ### New features - **Sharding tests for parallel execution on many devices 🎉** ([#1732](https://github.com/mobile-dev-inc/maestro/pull/1732) by [Kaan](https://github.com/sdfgsdfgd)) You can now pass `--shards` argument to `maestro test` to split up your test suite into chunks that run in parallel. If you have feedback or suggestions about this huge new feature, please share them with us in [issue #1818](https://github.com/mobile-dev-inc/maestro/issues/1818). - **Reports in HTML** ([#1750](https://github.com/mobile-dev-inc/maestro/pull/1750) by [Depa Panjie Purnama](https://github.com/depapp)) To see it, run `maestro test --format HTML ` - **Homebrew is back!** If you prefer to switch your installation of Maestro to use Homebrew: 1. `rm -rf ~/.maestro` 2. `brew tap mobile-dev-inc/tap && brew install maestro` 🎉 Script install method is still supported. - **Current platform exposed in JavaScript** ([#1747](https://github.com/mobile-dev-inc/maestro/pull/1747) by [Dan Caseley](https://github.com/Fishbowler)) In JavaScript, you can now access `maestro.platform` to express logic that depends on whether the test runs on iOS or Android. - **Control airplane mode** ([#1672](https://github.com/mobile-dev-inc/maestro/pull/1672) by [NyCodeGHG](https://github.com/NyCodeGHG)) New commands: `setAirplaneMode` and `toggleAirplaneMode`. Android-only because of iOS simulator restrictions. - **New `killApp` command** ([#1727](https://github.com/mobile-dev-inc/maestro/pull/1727) by [Alexandre Favre](https://github.com/alexandrefavre4)) To trigger a System-Initiated Process Death on Android. On iOS, works the same as `stopApp`. ### Bug fixes - Fix cleaning up retries in iOS driver ([#1669](https://github.com/mobile-dev-inc/maestro/pull/1669)) - Fix some commands not respecting custom labels ([#1762](https://github.com/mobile-dev-inc/maestro/pull/1762) by [Dan Caseley](https://github.com/Fishbowler)) - Fix “Protocol family unavailable” when rerunning iOS tests ([#1671](https://github.com/mobile-dev-inc/maestro/pull/1671) by [Stanisław Chmiela](https://github.com/sjchmiela)) ## 1.36.0 - 2024-02-15 - Feature: Add support for extra keys to Android TV - Feature: Add support for pressing tab key on Android - Feature: Add status and time to report.xml - Fix: Extend retry to handle 404 in upload status call - Fix: Crashes caused by toasts on Android API < 30 ## 1.35.0 - 2024-01-08 - Change: Adds view class to Android hierarchy output - Change: Improves description of maestro start-device command to include device locale as well - Change: Adds scrollable attribute to Android view hierarchy output - Feature: Adds childOf attribute to selector to select from children of a container - Feature: Adds label attribute to customize the CLI output of maestro commands - Fix: Fixing “Unsupported architecture UNKNOWN” on linux environment when calling maestro attempts to create devices - Fix: Allow maestro to work below API level 25 for Android - Fix: IllegalArgumentException on swipe operation for iOS if the coordinates beyond device width and height are selected ## 1.34.5 - 2024-01-04 - Feature: Adds a parameter to exclude all the keyboard elements from hierarchy ## 1.34.4 - 2023-12-27 - Fix: Failures due to swipe ranges going beyond screen dimensions - Change: Adding escape key in `pressKey` API - Tweak: Avoid returning `Result` in IOSDriver install and clearAppState ## 1.34.3 - 2023-11-21 - Tweak: Include scrollable attribute in view hierarchy from Android Driver - Feature: Custom labels for readability of maestro commands - Feature: Adding childOf selector - Tweak: Message of start-device command to show locale as well ## 1.34.2 - 2023-11-13 - Tweak: Include view class in view hierarchy attributes from the Android driver ## 1.34.1 - 2023-11-9 - Feature: add support `--device-locale` parameter for `maestro cloud` command - Feature: add support iOS17 for `maestro start-device` command - Feature: add support Android API level 34 for `maestro start-device` command ## 1.34.0 - 2023-10-24 - Feature: support `--device-locale` parameter for `maestro start-device` - Feature: add `centerElement` parameter for `scrollUntilVisible`. Center element will attempt to stop scrolling when the element is near the center of the screen. - Feature: add `power` button support for `pressKey` on Android - Change: add `tapOn` parameter `waitToSettleTimeoutMs` to control how long it waits to move on to the next command. Helpful for animation heavy apps. - Change: improve executionOrder planning - Change: improve retry mechanism to ensure openness of XCUITest Server - Fix: improve `TimeoutException` for driver startup ## 1.33.1 - 2023-10-03 - Feature: support for multipart form data file upload in Javascript, thanks @maciejkrolik - Fix: setPermissions produces error on Xcode 15 - Fix: Maestro studio - include enter key in command editor on initial paste ## 1.33.0 - 2023-09-21 - Feature: Adds MAESTRO_DRIVER_STARTUP_TIMEOUT to iOS driver to configure timeout to start iOS driver, used in CI/CD environment with performance limitations. Thanks, Jesse Farsong for contributing. - Feature: Introducing the "addMedia" command that enables adding images and videos directly to the devices. - Change: Improved Studio's user interface: - Updated fonts to align with company branding. - Introduced a distinct loading animation for better clarity when AI is processing commands. - Fix: Crash resulting in Error: No matches found for first query match sequence: `Children matching type Other` due to resolving root element for a snapshot operation on iOS - Fix: Android driver getting stuck when the device was disconnected - Fix: XCTestUnreachable exceptions due to missing IPv6 config on /etc/hosts - Fix: Handling app crash errors from XCUITest drivers gracefully - Fix: Timeouts can be separated with `_`. For example 10_000 for 10000 ## 1.32.0 - 2023-09-06 Studio - Feature: Support writing Flows using AI (more info to come 🚀) - Feature: Maestro Studio can now run in multiple tabs simultaneously - Feature: Added element id and copy option for it - Tweak: Hide action buttons till command is hovered - Tweak: Hide Unnecessary Scrollbars - Tweak: Repl view scroll improvements - Tweak: Improve Maestro Studio performance - Fix: Selected element size - Fix: Performance issues with maestro studio device refresh - Fix: Fixed dark mode for element id CLI - Feature: New command to start or create a Maestro recommended device (docs) - Feature: Support id selection for testID with react-native-web (community contribution) - Feature: Control if browser automatically opens when running Maestro Studio via --no-window (community contribution) - Tweak: Show cancellation reason when available (Maestro Cloud) - Tweak: Update selenium-java and remove webdrivermanager to support Chrome 116+ - Tweak: Show device type when running on Maestro Cloud - Tweak: Added better messaging and recovery options for Maestro Cloud uploads (useful for CI) - Tweak: Added better error messages for missing workspace and yaml validation errors - Tweak: Added file name and line number in yaml parsing error messages - Fix: Input text and erase text stability improvements for iOS - Fix: Leaking response body on iOS & better error handling for iOS Driver - Fix: Fixed Maestro Cloud wrong exit code when flow failed - Fix: Debug commands parsing would crash maestro - Fix: Cleaning up debug logs ## 1.31.0 - 2023-08-10 - Fix: Warning shown from OkHttp for leaking response bodies on CLI - Closing response bodies for retries done on the XCUITest driver - Closing response bodies for permissions - Removing different thread execution done on hideKeyboard - Fix: Scroll for React native apps on screens with large view hierarchies on iOS - Fix: Showing more descriptive errors on flow file not found during maestro cloud command. - Fix: Input text characters being skipped or being appended later in the test on iOS - Fix: Crash in debug output generation when maestro flow contains "/"’ - Fix: Resolved issue where tapping on the device in maestro studio produced inaccurate click locations due to incorrect coordinates. Now fixed for accurate device interaction - Fix: In Maestro Studio, the issue of window resizing causing devices to overflow off the screen has been resolved. - Feature: Add headers to HTTP response for API calls done with Maestro. Thanks, Jesse Willoughby! for this contribution. - Feature: Now it is possible to configure the path with the –debug-output option for debugging information that maestro dumps in the user directory by default. - Feature: Enhanced Maestro Studio with keyboard accessibility, streamlining navigation and facilitating the copy, run, and edit commands using the keyboard. - Change: Fail the test if any of the onFlowStart or onFlowComplete hooks fail - Change: Removed IDB on iOS. This may impact the performance of maestro commands like tapOn and assertVisible on iOS screens with large view hierarchies. - Studio and CLI will now provide insights and warnings in case the hierarchy of these screens becomes extensive. - Change: In Maestro Studio, we've integrated screenshots of selected elements alongside their corresponding commands. - Change: In Maestro Studio, double-clicking will now execute the command. ## 1.30.4 - 2023-07-19 - Fix: correctly resolve external parameters for onStart/Complete hooks - Fix: reuse JSEngine for all executeCommands (hooks, main commands, subflows) actions ## 1.30.3 - 2023-07-17 - Update: Maestro Studio revamp improvements - wrapped element names in sidebar - sidebar text always visible - add "hintText" and "accessibilityText" in sidebar - improve sidebar search - fixed highlight issues in search - various other small improvements ## 1.30.2 - 2023-07-14 - Revert connection improvements (from 1.30.1) ## 1.30.1 - 2023-07-14 - Fix: Allow running `maestro studio` and `maestro test` simultaneously - Fix: Connection improvements ## 1.30.0 - 2023-07-13 - Feature: onFlowStart / onFlowComplete hooks - Feature: Maestro Studio revamp - improved design - search components panel - improved drag-and-drop - Feature: Introduce `--app-binary-id` parameter for Maestro Cloud upload action to be able to re-use a previously uploaded app for different flows - Feature: Implement Experimental GraalJsEngine (ECMAScript 2022 compliant) - Fix: Save xctest xcodebuild logs output to system temp dir - Fix: Close existing screen recording if it was left open. - Thanks, @carlosmuvi, for the contribution! - Fix: Execute sequential Flows even if no other Flows are present - Fix: Various XCTestClient connection improvements - Deprecate: `assertOutgoingRequestsCommand` - Deprecate: Network Mocking feature - Deprecate: Maestro Mock Server feature ## 1.29.0 - 2023-06-19 - Feature: Add test duration measurement and display - Feature: New screen recording commands - Thanks, @tokou, for the contribution! - Feature: Add support for sequential execution - Feature: Add support for double taps + multiple taps in tapOn - Feature: Add support for custom Android driver startup timeout - Thanks, @arildojr7, for the contribution! - Fix: Validate workspace prior to upload to Maestro Cloud - Fix: Resolve Android scrollUntilVisible flakiness - Fix: Resolve inputText flakiness - Fix: iOS url arguments - Thanks, @tokou, for the contribution! ## 1.28.0 - 2023-05-18 - Feature: runScript command now support conditional execution - Feature: Improved debug output: - Shows failure reason when command fails - Generates screenshot when command fails - Unified most logs under ~/.maestro/tests//maestro.log - Change: Launch arguments support for long values - Tweak: JUnit report naming changes. Local and Cloud should now have the same naming convention. - Tweak: Added deprecation notice for experimental features - Fix: maestro record command was not working on iOS - Fix: WebDriver, only scroll to elements outside of the window before tapping - Fix: close request leaking body - Fix: maestro cloud now will fail on timeout if configured as such ## 1.27.0 - 2023-05-02 - Feature: Adds assertOutgoingRequests to assert the network requests from the app - Feature: Add platform condition in runFlow command to do platform-specific orchestration. Thanks, Larry Ng for your contribution! - Feature: Adds a new selector containsDescendants. Thanks, Larry Ng for your contribution! - Feature: iOS and Android launch arguments - Change: Include the update command instead of update instructions in the update message. Thanks @bobpozun for your contribution! - Fix: Fixes swipe flakiness caused due to waiting for animations to complete on XCTest - Fix: Correctly resolving `maestro.copiedText` - Fix: Using deviceId instead of booted, potentially resolving XCTestUnreachable exceptions. - Fix: Improving waitForAppToSettle for Android by accounting window updates. Resolves maestro command interaction in Android 13. - Fix: Notification permissions not getting granted - Fix: Use correct documentation URLs in Studio ## 1.26.1 - 2023-04-13 - Fix: hideKeyboard crashing on react native apps because swipe fails on some screens ## 1.26.0 - 2023-04-13 - Feature: Adds Travel command to mock motion for app - Feature: Adds a capability to match the toast messages - Feature: Add support for console.log in javascript - Feature: Allow writing inline flows with runFlow command - Change: Adds sms permission to permission names which can be used to allow/deny: android.permission.READ_SMS, android.permission.RECIEVE_SMS, android.permission.SEND_SMS. Thanks, @depapp for the contribution. - Change: Maestro can now also match hint text and values of text field. - Change: Maestro can now also match elements with their accessibility text. - Commands moved away from IDB: - Long press is now done with XCTest instead of idb - Installation of app is now done with simctl commands - Hide keyboard with help of XCTest. We now scroll up and down from the middle of the screen to close the keyboard. - Press key now is done with XCTest. - Note that with this change pressKey: Enter now only wraps on new line - earlier it also closed the keyboard - Erase text is now done with XCTest. - Use simctl to record screen - Fix: Web driver no longer crashes when using latest Chrome - Fix: Fixes hideKeyboard on android by appropriately dispatching proper event. Thanks, @nhaarman for contribution - Fix: Properly shutting down studio by listening to SIGTSP signal - Fix: Update granting of notifications and health permissions causing simulator restarts and XCTestUnreachableExceptions. ## 1.25.0 - 2023-03-13 - Fix: Shell environment variables can no longer crash the javascript runtime - Fix: XCTestRunner and IDB are restarted on connection error - Feature: Add support for setLocation ## 1.24.0 - 2023-03-07 - Change: LaunchApp command sets all app permissions to allow ([documentation](https://maestro.mobile.dev/reference/app-lifecycle)) - Feature: LaunchApp supports specifying app permission state - Feature: On Android it is now possible to force links to be opened in the browser - Fix: Autocorrect is no longer applied to inputText on iOS - Fix: iOS apps with big view hierarchies (common with ReactNative and Flutter) caused an error in XCTest.framework - Fix: Studio UI fixes for Firefox and Safari - Fix: Element selection behavior in Maestro Studio ## 1.23.0 - 2023-02-15 - Feature: Maestro Studio - Action Modal - Feature: Maestro Studio - Dark Mode - Feature: assertion on `enabled`, `selected`, `checked`, `focused` properties ([documentation](https://maestro.mobile.dev/reference/assertions#assertvisible)) - Feature: running tests in a deterministic order ([documentation](https://maestro.mobile.dev/cli/test-suites-and-reports#deterministic-ordering)) - Feature: default global tags can now be set in `config.yaml` ([documentation](https://maestro.mobile.dev/cli/tags#global-tags)) - Feature: allow to configure what flows should be included into a run at `config.yaml` level ([documentation](https://maestro.mobile.dev/cli/test-suites-and-reports#controlling-what-tests-to-include)) - Tweak: considerable speed-up of iOS tests due to removal of unnecessary hierarchy polling - Tweak: wait for app to settle before proceeding with iOS test - Tweak: UX improvements in "delete command" confirmation dialog - Tweak: using `xcrun` for uninstall command on iOS - Tweak: using `xcrun` for clearKeychain command on iOS - Tweak: using `.maestro` directory by default for mockserver deploy command - Fix: errors were clipped in Maestro Studio - Fix: use element title as id in Web driver - Fix: Repeat-while-true did not work properly with JavaScript conditions - Fix: Repeat-times did not work properly with JavaScript input - Fix: added artificial delay after key presses (i.e. "back" key) on Android ## 1.22.1 - 2023-02-09 - Early Access Feature: Maestro Mock Server and Maestro SDK (Android preview) - Tweak: added visibility threshold and scroll speed to `scrollUntilVisible` command - Tweak: speed up `tapOn` command on iOS - Fix: removing view hierarchy elements that are out of screen bounds - Fix: `inputText` command skipping characters on iOS - Fix: Reworked `clearAppState` behaviour on iOS, solving issue that caused crashes after clearing the state - Fix: crash when running multiple Maestro sessions in parallel while using iOS device - Fix: a rare crash in React Native apps when trying to input a long string on iOS - Fix: properly handling linebreaks in Maestro Studio ## 1.21.3 - 2023-01-30 - Fix: `scrollUntilVisible` was not always working on iOS - Tweak: speed up tests by skipping an unnecessary hierarchy poll - Tweak: iOS screenshot no longer depends on IDB and is faster ## 1.21.2 - 2023-01-26 - Hotfix: Move iOS tap() implementation back to IDB to resolve problems with React Native apps - Fix: running multiple Maestro instances would sometimes result in Connection exception - Fix: support JS injection in `scrollUntilVisible` command ## 1.21.1 - 2023-01-25 - Fix: Increase typing speed for iOS text input ## 1.21.0 - 2023-01-25 - Feature: Next evolution of Maestro Studio - Fix: More robust implementation of inputText on iOS - Fix: More robust implementation of tap on iOS - Experimental: Added web driver ## 1.20.0 - 2023-01-24 - Feature: Maestro Studio - use percentage-based swiping - Feature: Scroll until view element is visible - Feature: Relatively swipe with percentage based start and end coordinates - Fix: Android tap was not always working - Fix: Bottom of Android hierarchy was cut off - Fix: idb_companion fails to start due to gRPC timeout exception - Tweak: Improve Android Screenshot Internal Logic - Tweak: Change the end coordinates for swipe element - Tweak: Update sample flows ## 1.19.5 - 2023-01-19 - Fix: inputText was not working on iOS React Native apps - Fix: Maestro fails to launch on iOS if --device parameter is present - Fix: Evaluate JS scripts with element selector in swipe command - Tweak: added tags to sample flows - Tweak: indicating whether build is running on CI in analytics ## 1.19.2 - 2023-01-17 - Hotfix: Maestro Studio was not working ## 1.19.1 - 2023-01-17 - Feature: generating test report from `maestro cloud` output - Fix: in rare cases, maestro cloud was computing progress bar as negative value - Fix: local test suite included non-flow files - Fix: some special characters were not allowed in env variables (i.e. `&`) - Fix: vertical scrolling was sometimes not working on iOS - Fix: if a text string is an invalid regex, treat it as a regular string instead - Fix: scroll and swipe commands on iOS were throwing an error when running in parallel with Maestro Studio - Tweak: print out valid inputs for `--format` parameter in `maestro test` and `maestro upload` - Tweak: removed Maestro Studio warning related to parallel execution - Refactor: making XCTestDriver configurable ## 1.19.0 - 2023-01-13 - Feature: iOS unicode input support + non-English keyboards - Feature: `swipe` command now supports `from` argument to swipe from a given view - Feature: `repeat` command now supports `while` condition - Feature: Allowing `extendedWaitUntil` command to use env values in `timeout` property - Tweak: assert commands now respect `optional` flag - Tweak: error analytics - Fix: scroll not working reliably on iOS - Fix: `openLink` was opening Google Maps on Android - Fix: sub-flows are now included regardless of their tags - Fix: Maestro Studio was not always computing `index` field correctly - Fix: `maestro upload` was ignoring JS files - Fix: `openLink` command now supports query parameters ## 1.18.5 - 2023-01-10 - Feature: tags - Tweak: allow running other maestro commands alongside Maestro Studio - Tweak: improved matching for strings with linebreaks - Fix: creating maestro logs directory was not always working properly - Fix: maestro studio was not working properly on Kubuntu ## 1.18.3 - 2022-12-27 - XCUITest driver improvements and fixes: - Close the response when validating server up - Add logs to uninstall of runner - Remove redundant import and library from maestro-ios - Kills the process before we uninstall it - Redirect runner logs in xctest_runner_logs directory ## 1.18.2 - 2022-12-27 - Fix: Wait for XCUITest server to start before proceeding ## 1.18.1 - 2022-12-27 - Fix: Create XCUITest driver HTTP server on loopback address - Fix: Create parity with idb for `text` attribute with following priority: - Title - Label - Value ## 1.18.0 - 2022-12-26 - Feature: Adds new XcUITest driver to capture view hierarchy on iOS. - Fixes stability issues on iOS 16 - Fixes not identified bottom navigation tabs - Gets view hierarchy natively from XCUITest - Fix: Missing letter j and y in inputRandomText command - Tweak: Un-deprecate the hierarchy command, inform about Studio - Tweak: Match negative bounds as well in maestro studio - Feature: Adds replay functionality in maestro studio - Feature: Adding device interaction to interact page in Maestro Studio ## 1.17.4 - 2022-12-15 - Fix: Maestro commands were failing if Android SDK wasn't installed ## 1.17.3 - 2022-12-15 - Feature: no-ansi version for terminals that do not ANSI - Feature: Android Maven artifact for setting up network mocking - Fix: Android emulator was not discovered properly if it wasn't on PATH - Fix: missing favicon ## 1.17.2 - 2022-12-13 - Tweak: Deprecate hierarchy and query CLI commands ## 1.17.1 - 2022-12-12 - Tweak: Remove Maestro Studio icon from Mac dock - Tweak: Prefer port 9999 for Maestro Studio app - Fix: Fix Maestro Studio conditional code snippet ## 1.17.0 - 2022-12-12 - Feature: Maestro Studio - Feature: Print a message when an update is available - Feature: Support percentages for tapOn - Fix: Maestro commands execute faster now - Fix: Fix environment variable substitution in certain cases - Fix: Use actual android device screen size (including nav bar) ## 1.16.4 - 2022-12-02 - Fix: Add error message for when an Android screen recording fails ## 1.16.3 - 2022-12-02 - Fix: Fix iOS `clearState` not working in certain cases - Fix: Fix `maestro record` not capturing full launch screen recording ## 1.16.2 - 2022-12-02 - Fix: older version of Maestro Driver on Android was not always updated ## 1.16.1 - 2022-11-30 - Feature: `maestro record` command - Fix: `z` character was not inputted correctly on Android ## 1.16.0 - 2022-11-29 - Feature: Javascript injection support - `runScript` and `evalScript` commands to run scripts - `assertTrue` command to assert based on Javascript - `runFlow` can be launched based on Javascript condition - `copyTextFrom` now also stores result in `maestro.copiedText` variable - Env parameters are now treated as Javascript variables - Feature: HTTP(s) requests - `http.request()` Javascript API that allows to make HTTP requests as part of Maestro flows - Feature: Maestro Cloud `--android-api-level` parameter to select API version to be used - Feature: `waitForAnimationToEnd` command to wait until animations/videos are finished - Tweak: test reports can now be generated for single test runs (and not just folders) - Tweak: `inputText` on Android was reworked to increase speed and input stability - Tweak: `eraseText` is now much faster - Tweak: `maestro cloud` will automatically retry upload up to 3 times - Fix: running on Samsung devices was sometimes failing because of wrong user being used ## 1.15.0 - 2022-11-17 - Feature: run all tests in a folder as a suite - Feature: XML test report in JUnit-compatible format - Feature: `copyTextFrom` command for copying text from a view - Feature: `maestro bugreport` command for capturing Maestro logs - **Breaking change**: Removed `clipboardPaste` command in favour of new `pasteText` command - Fix: Java 8 compatibility issue for M1 users - Fix: `_` character was mapped incorrectly on iOS - Fix: first `tapOn` command was failing unless it was preceded by `launchApp` or `openLink` - Tweak: Maestro no longer kills running `idb_companion` processes - Tweak: updated gRPC version to 1.52.0 ## 1.14.0 - 2022-11-14 - Fix: passing env parameters to subflows and other env params - Speeding up maestro flows - Checking in maestro sample flows and adds sample updating guide - Maestro is now compatible with java 8! - Launching app without stopping the app - Fixing launching app when resolving launcher activity throws `NullPointerException` ## 1.13.2 - 2022-11-10 - Fix: Fallback properly on monkey when start-activity command fails, when launching app. ## 1.13.1 - 2022-11-09 - Fix: Fix maestro hanging with message "Waiting for idb service to start.." - Fix: Fix clearState operation not working on iOS ## 1.13.0 - 2022-11-08 - Feature: Option to set direction and speed for swipe command - Fix: Fix duplicate and unavailable iOS simulators in list - Fix: Longer timeout for iOS simulator boot ## 1.12.0 - 2022-11-06 - Feature: `maestro cloud` command added ## 1.11.4 - 2022-11-02 - Fix: Use absolute path to prevent NullPointerException when .app folder is in the cwd - Fix: Create parent directory if not exists when generating adb key pair, updates dadb to 1.2.6 - Fix: Opening of leak canary app - Tweak: send agent: ci when known CI environment variables are set ## 1.11.3 - 2022-10-29 - Fix: updating to dadb 1.2.4 ## 1.11.2 - 2022-10-29 - Fix: updating to dadb 1.2.3 to fix an occasional device connection issue - Fix: injecting `env` parameters into conditions (i.e. in `runFlow`) ## 1.11.1 - 2022-10-27 - Fix: closing `idb_companion` after `maestro` completes ## 1.11.0 - 2022-10-26 - Feature: `maestro` will offer user to select a device if one is not running already - Feature: `env` variables can be inlined in flow file or in `runFlow` command - **Breaking change**: `--platform` option is deprecated. CLI now prompts user to pick a device. - Tweak: auto-starting `idb_companion`. No need to start it manually anymore. - Tweak: tripled Android Driver launch timeout - Tweak: customisable error resolution in Orchestra - Fix: `maestro upload` was not ignoring `-e` parameters ## 1.10.1 - 2022-10-12 - Fix: login command fails with java.lang.IllegalStateException: closed ## 1.10.0 - 2022-10-12 - Feature: `repeat` command that allows to create loops - Feature: conditional `runFlow` execution that allows to create if-conditions - Feature: `inputRandomText`, `inputRandomNumber`, `inputRandomEmail` and `inputRandomPersonName` commands (thanks @ttpho !) - Feature: `clipboardPaste` command (thanks @depapp !) - Feature: Added `enabled` property to element selector - Feature: Added `download-samples` command to allow quickstart without having to build your own app - Feature: Added `login` and `logout` commands for interacting with mobile.dev - **Breaking change:** `upload` now takes 1 less argument. `uploadName` parameter was replaced with `--name` optional argument - Tweak: `upload` command automatically zips iOS apps - Tweak: sending `agent: cli` value alongside `upload` and `login` commands - Fix: properly compare fields that contain regex special symbols - Fix: input text on Android was sometimes missing characters ## 1.9.0 - 2022-09-30 - Feature: USB support for Android devices ## 1.8.3 - 2022-09-28 - Fix: occasional crash when an iOS layout has a view group with a 0 width - Fix: properly mapping top-level syntax errors ## 1.8.2 - 2022-09-27 - Tweak: prioritise clickable elements over non-clickable ones - Fix: close TCP forwarder if it is already in use - Fix: hideKeyboard on Android did not always work ## 1.8.1 - 2022-09-27 - Fix: Timeout exception while opening port for tcp forwarding ## 1.8.0 - 2022-09-22 - Feature: `runFlow` command - Tweak: support of Tab Bar on iOS - Tweak: added `--mapping` option to `upload` CLI command - Fix: open the main launcher screen on Android instead of Leak Canary - Fix: input character-by-character on Android to counter adb issue where not the whole text gets transferred to the device ## 1.7.2 - 2022-09-20 - Fix: `tapOn` command was failing due to a failure during screenshot capture ## 1.7.1 - 2022-09-19 - Feature: `clearState` command - Feature: `clearKeychain` command - Feature: `stopApp` command - Tweak: Maestro now compares screenshots to decide whether screen has been updated - Tweak: `launchApp` command now supports env parameters ## 1.7.0 - 2022-09-16 - Feature: `maestro upload` command for uploading your builds to mobile.dev - Feature: `takeScreenshot` command - Feature: `extendedWaitUntil` command - Fix: waiting for Android gRPC server to properly start before interacting with it - Fix: brought back multi-window support on Android - Fix: `hideKeyboard` command did not always work - Fix: make project buildable on Java 14 - Refactoring: make `MaestroCommand` serializable without custom adapters - Refactoring: migrated to JUnit 5 ## 1.6.0 - 2022-09-13 - Feature: hideKeyboard command - Feature: add Android TV Remote navigation - Tweak: allowing to skip package name when searching by `id` - Fix: Android WebView contents were sometimes not reported as part of the view hierarchy - Fix: iOS inputText race condition - Fix: populate iOS accessibility value - Refactoring: simplified `MaestroCommand` serialization ## 1.5.0 - 2022-09-08 - Temporary fix: showing an error when unicode characters are passed to `inputText` - Feature: `eraseText` command ## 1.4.2 - 2022-09-06 - Fix: Android devices were not discoverable in some cases ## 1.4.1 - 2022-09-05 - Fix: relative position selectors (i.e. `below`) were sometimes picking a wrong view - Fix: await channel termination when closing a gRPC ManagedChannel - Fix: Android `inputText` did not work properly when a string had whitespaces in it - Fix: race condition in iOS `inputText` ## 1.4.0 - 2022-08-29 - Added `traits` selector. - Relative selectors (such as `above`, `below`, etc.) are now picking the closest element. - Fix: continuous mode did not work for paths without a parent directory - Fix: workaround for UiAutomator content descriptor crash - Fix: `tapOn: {int}` did not work ## 1.3.6 - 2022-08-25 - Added `longPressOn` command - Decreased wait time in apps that have a screen overlay - Fixed CLI issue where status updates would not propagate correctly ## 1.3.3 - 2022-08-23 - Fix: iOS accessibility was not propagated to Maestro ## 1.3.2 - 2022-08-22 - Fix: env parameters did not work with init flows when using `Maestro` programmatically ## 1.3.1 - 2022-08-19 - Added support for externally supplied parameters - Added `openLink` command ## 1.2.6 - 2022-08-18 - Fail launching an iOS app if the app is already running ## 1.2.4 - 2022-08-17 - Add support for cli to specify what platform, host and port to connect to ## 1.2.3 - 2022-08-15 - Added support of iOS state restoration - Exposing `appId` field as part of `MaestroConfig` ## 1.2.2 - 2022-08-08 - Update `Orchestra` to support state restoration ## 1.2.1 - 2022-08-04 - Update `YamlCommandReader` to accept Paths instead of Files to support zip Filesystems ## 1.2.0 - 2022-08-04 - Config is now defined via a document separator - launchApp no longer requires and appId - initFlow config implemented ## 1.1.0 - 2022-07-28 - `launchApp` command now can optionally clear app state - `config` command to allow Orchestra consumers a higher degree of customization - Fixed a bug where `ElementNotFound` hierarchy field was not declared as public ## 1.0.0 - 2022-07-20 - Initial Maestro release (formerly known as Conductor) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Maestro Thank you for considering contributing to the project! We welcome contributions from everyone and generally try to be as accommodating as possible. However, to make sure that your time is well spent, we separate the types of contributions in the following types: - Type A: Simple fixes (bugs, typos) and cleanups - You can open a pull request directly, chances are high (though never guaranteed) that it will be merged. - Type B: Features and major changes (i.e. refactoring) - Unless you feel adventurous and wouldn't mind discarding your work in the worst-case scenario, we advise to open an issue or a PR with a suggestion first where you will describe the problem you are trying to solve and the solution you have in mind. This will allow us to discuss the problem and the solution you have in mind. ### Side-note on refactoring Our opinion on refactorings is generally that of - don't fix it if it isn't broken. Though we acknowledge that there are multiple areas where code could've been structured in a cleaner way, we believe there are no massive tech debt issues in the codebase. As each change has a probability of introducing a problem (despite all the test coverage), be mindful of that when working on a refactoring and have a strong justification prepared. ## Lead times We strive towards having all public PRs reviewed within a week, typically even faster than that. If you believe that your PR requires more urgency, please contact us on a public Maestro Slack channel. Once your PR is merged, it usually takes about a week until it becomes publicly available and included into the next release. ## Developing ### Requirements Maestro's minimal deployment target is Java 17, and for development, you need to use Java 17 or newer. If you made changes to the CLI, rebuilt it with `./gradlew :maestro-cli:installDist`. This will generate a startup shell script in `./maestro-cli/build/install/maestro/bin/maestro`. Use it instead of globally installed `maestro`. If you made changes to the iOS XCTest runner app, make sure they are compatible with the version of Xcode used by the GitHub Actions build step. It is currently built using the default version of Xcode listed in the macos runner image [readme][macos_builder_readme]. If you introduce changes that work locally but fail to build when you make a PR, check if you used a feature used in a newer version of Swift or some other new Xcode setting. ### Debugging Maestro stores logs for every test run in the following locations: - CLI Logs: `~/.maestro/tests/*/maestro.log` - iOS test runner logs: `~/Library/Logs/maestro/xctest_runner_logs` ### Android artifacts Maestro requires 2 artifacts to run on Android: - `maestro-app.apk` - the host app. Does nothing. - `maestro-server.apk` - the test runner app. Starts an HTTP server inside an infinite JUnit/UIAutomator test. These artifacts are built by `./gradlew :maestro-android:assemble` and `./gradlew :maestro-android:assembleAndroidTest`, respectively. They are placed in `maestro-android/build/outputs/apk`, and are copied over to `maestro-client/src/main/resources`. ### iOS artifacts Maestro requires 3 artifacts to run on iOS: - `maestro-driver-ios` - the host app for the test runner. Does nothing and is not installed. - `maestro-driver-iosUITests-Runner.app` - the test runner app. Starts an HTTP server inside an infinite XCTest. - `maestro-driver-ios-config.xctestrun` - the configuration file required to run the test runner app. These artifacts are built by the `build-maestro-ios-runner.sh` script. It places them in `maestro-ios-driver/src/main/resources`. ### Running standalone iOS XCTest runner app The iOS XCTest runner can be run without Maestro CLI. To do so, make sure you built the artifacts, and then run: ```console ./maestro-ios-xctest-runner/run-maestro-ios-runner.sh ``` This will use `xcodebuild test-without-building` to run the test runner on the connected iOS device. Now, you can reach the HTTP server that runs inside the XCTest runner app (by default on port 22087): ```console curl -fsSL -X GET localhost:22087/deviceInfo | jq ```
See example output ```json { "heightPoints": 852, "heightPixels": 2556, "widthPixels": 1179, "widthPoints": 393 } ```
```console curl -fsSL -X POST localhost:22087/touch -d ' { "x": 150, "y": 150, "duration": 0.2 }' ``` ```console curl -sSL -X GET localhost:22087/swipe -d ' { "startX": 150, "startY": 426, "endX": 426, "endY": 350, "duration": 1 }' ``` ### Artifacts and the CLI `maestro-cli` depends on both `maestro-ios-driver` and `maestro-client`. This is how the CLI gets these artifacts. ## Linting ```bash ./gradlew detekt # Run detekt code quality checks ./gradlew detektMain # Run detekt with type resolution ./gradlew detektBaseline # Generate baseline ``` ## Testing There are 3 ways to test your changes: - Integration tests - Run them via `./gradlew :maestro-test:test` (or from IDE) - Tests are using real implementation of most components except for `Driver`. We use `FakeDriver` which pretends to be a real device. - Manual testing - Run `./maestro` instead of `maestro` to use your local code. - Unit tests - All the other tests in the projects. Run them via `./gradlew test` (or from IDE) If you made changes to the iOS XCUITest driver, rebuild it by running `./maestro-ios-xctest-runner/build-maestro-ios-runner.sh`. ## Module structure | Module | Purpose | |--------|---------| | `maestro-cli` | CLI entry point and user-facing commands | | `maestro-client` | `Maestro` class, `Driver` interface, core API | | `maestro-orchestra` | Flow execution, YAML parsing, scripting | | `maestro-orchestra-models` | Command data classes (serializable) | | `maestro-android` | Android driver implementation | | `maestro-ios` | iOS driver implementation | | `maestro-ios-xctest-runner` | Swift/Xcode XCTest runner app | | `maestro-web` | Web/CDP driver implementation | | `maestro-studio` | Studio IDE server | | `maestro-ai` | AI-powered test capabilities | | `maestro-test` | `FakeDriver` and testing utilities | | `maestro-utils` | Shared utilities | | `maestro-proto` | Protocol buffer definitions | | `e2e` | End-to-end test suites | ### Processing flow ``` YAML Flow File → YamlCommandReader → List → Orchestra.executeFlow() → Maestro API → Driver → Device ``` ## Architectural considerations Keep the following things in mind when working on a PR: - `Maestro` class is serving as a target-agnostic API between you and the device. - `Maestro` itself should not know or care about the concept of commands. - `Orchestra` class is a layer that translates Maestro commands (represented by `MaestroCommand`) to actual calls to `Maestro` API. - `Maestro` and `Orchestra` classes should remain completely target (Android/iOS/Web) agnostic. - Use `Driver` interface to provide target-specific functionality. - Maestro commands should be as platform-agnostic as possible, though we do allow for exceptions where they are justified. - Maestro CLI is supposed to be cross-platform (Mac OS, Linux, Windows). - Maestro is designed to run locally as well as on Maestro Cloud. That means that code should assume that it is running in a sandbox environment and shouldn't call out or spawn arbitrary processes based on user's input - For that reason we are not allowing execution of bash scripts from Maestro commands. - For that reason, `MaestroCommand` class should be JSON-serializable (and is a reason we haven't moved to `sealed class`) - Prefer fakes over mocks (e.g. `FakeDriver`). Mocks (MockK) are used in some modules but fakes are the preferred approach for driver-level testing. This graph (generated with [`./gradlew :generateDependencyGraph`][graph_plugin] in [PR #1834][pr_1834]) may be helpful to visualize relations between subprojects: ![Project dependency graph](assets/project-dependency-graph.svg) ## How to ### Add new command Follow these steps: - Define a new command in `Commands.kt` file, implementing `Command` interface. - Add a new field to `MaestroCommand` class, following the example set by other commands. - Add a new field to `YamlFluentCommand` to map between yaml representation and `MaestroCommand` representation. - Handle command in `Orchestra` class. - If this is a new functionality, you might need to add new methods to `Maestro` and `Driver` APIs. - Add a new test to `IntegrationTest`. [macos_builder_readme]: https://github.com/actions/runner-images/blob/main/images/macos/macos-14-Readme.md [graph_plugin]: https://github.com/vanniktech/gradle-dependency-graph-generator-plugin [pr_1834]: https://github.com/mobile-dev-inc/maestro/pull/1834 ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ > [!TIP] > Great things happen when testers connect — [Join the Maestro Community](https://maestrodev.typeform.com/to/FelIEe8A)

Maestro logo

Maestro is an open-source framework that makes UI and end-to-end testing for Android, iOS, and web apps simple and fast.
Write your first test in under five minutes using YAML flows and run them on any emulator, simulator, or browser.

--- ## Table of Contents - [Why Maestro?](#why-maestro) - [Getting Started](#getting-started) - [Resources & Community](#resources--community) - [Contributing](#contributing) - [Maestro Studio – Test IDE](#maestro-studio--test-ide) - [Maestro Cloud – Parallel Execution & Scalability](#maestro-cloud--parallel-execution--scalability) --- ## Why Maestro? Maestro is built on learnings from its predecessors (Appium, Espresso, UIAutomator, XCTest, Selenium, Playwright) and allows you to easily define and test your Flows. By combining a human-readable YAML syntax with an interpreted execution engine, it lets you write, run, and scale cross-platform end-to-end tests for mobile and web with ease. - **Cross-platform coverage** – test Android, iOS, and web apps (React Native, Flutter, hybrid) on emulators, simulators, or real devices. - **Human-readable YAML flows** – express interactions as commands like `launchApp`, `tapOn`, and `assertVisible`. - **Resilience & smart waiting** – built-in flakiness tolerance and automatic waiting handle dynamic UIs without manual `sleep()` calls. - **Fast iteration & simple install** – flows are interpreted (no compilation) and installation is a single script. **Simple Example:** ``` # flow_contacts_android.yaml appId: com.android.contacts --- - launchApp - tapOn: "Create new contact" - tapOn: "First Name" - inputText: "John" - tapOn: "Last Name" - inputText: "Snow" - tapOn: "Save" ``` --- ## Getting Started Maestro requires Java 17 or higher to be installed on your system. You can verify your Java version by running: ``` java -version ``` Installing the CLI: Run the following command to install Maestro on macOS, Linux or Windows (WSL): ``` curl -fsSL "https://get.maestro.mobile.dev" | bash ``` The links below will guide you through the next steps. - [Installing Maestro](https://docs.maestro.dev/getting-started/installing-maestro) (includes regular Windows installation) - [Build and install your app](https://docs.maestro.dev/getting-started/build-and-install-your-app) - [Run a sample flow](https://docs.maestro.dev/getting-started/run-a-sample-flow) - [Writing your first flow](https://docs.maestro.dev/getting-started/writing-your-first-flow) --- ## Resources & Community - 💬 [Join the Slack Community](https://maestrodev.typeform.com/to/FelIEe8A) - 📘 [Documentation](https://docs.maestro.dev) - 📰 [Blog](https://maestro.dev/blog?utm_source=github-readme) - 🐦 [Follow us on X](https://twitter.com/maestro__dev) --- ## Contributing Maestro is open-source under the Apache 2.0 license — contributions are welcome! - Check [good first issues](https://github.com/mobile-dev-inc/maestro/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) - Read the [Contribution Guide](https://github.com/mobile-dev-inc/Maestro/blob/main/CONTRIBUTING.md) - Fork, create a branch, and open a Pull Request. If you find Maestro useful, ⭐ star the repository to support the project. --- ## Maestro Studio – Test IDE **Maestro Studio Desktop** is a lightweight IDE that lets you design and execute tests visually — no terminal needed. It is also free, even though Studio is not an open-source project. So you won't find the Maestro Studio code here. - **Simple setup** – just download the native app for macOS, Windows, or Linux. - **Visual flow builder & inspector** – record interactions, inspect elements, and build flows visually. - **AI assistance** – use MaestroGPT to generate commands and answer questions while authoring tests. [Download Maestro Studio](https://maestro.dev/?utm_source=github-readme#maestro-studio) --- ## Maestro Cloud – Parallel Execution & Scalability When your test suite grows, run hundreds of tests in parallel on dedicated infrastructure, cutting execution times by up to 90%. Includes built-in notifications, deterministic environments, and complete debugging tools. Pricing for Maestro Cloud is completely transparent and can be found on the [pricing page](https://maestro.dev/pricing?utm_source=github-readme). 👉 [Start your free 7-day trial](https://maestro.dev/cloud?utm_source=github-readme) ``` Built with ❤️ by Maestro.dev ``` ================================================ FILE: RELEASING.md ================================================ # Production Releases ## Prepare 1. Define the next semantic version Semantic versioning: a.b.c - a: major breaking changes - b: new functionality, new features - c: any other small changes 2. Checkout the main branch and make sure it is up-to-date: `git checkout main && git pull` 3. Create a new branch 4. Update the CHANGELOG.md file with changes of this release, you should add a new section with your version number and the relevant updates, like the ones that exist on the previous versions 5. Change the version in `gradle.properties` 6. Change the version in `maestro-cli/gradle.properties` 7. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) 8. Submit a PR with the changes against the main branch 9. Merge the PR ## Tag 1. `git tag -a vX.Y.Z -m "Version X.Y.Z"` (where X.Y.Z is the new version) 2. `git push --tags` 3. Wait until all Publish actions have completed https://github.com/mobile-dev-inc/maestro/actions ## Publish Maven Central 1. Trigger the [Publish Release action](https://github.com/mobile-dev-inc/maestro/actions/workflows/publish-release.yml) - ATTENTION: Wait for it to finish 3. Go to [OSS Sonatype](https://s01.oss.sonatype.org/) and login with user/password 4. Go to Staging Repositories, select the repository uploaded from the trigger above. 5. Click "Close" and then "Release". Each of these operations take a couple minutes to complete ____________________________________________________________________________________________________________________________________________________ **CAUTION:** You should go back to the [notion document](https://www.notion.so/Maestro-Release-Run-Book-78159c6f80de4492a6e9e05bb490cf60?pvs=4) to see how to update the **Robin** and **Maestro Cloud** versions before updating the **CLI** ____________________________________________________________________________________________________________________________________________________ ## Publish CLI 1. Trigger the [Publish CLI Github action](https://github.com/mobile-dev-inc/Maestro/actions/workflows/publish-cli.yaml) 2. Test installing the cli by running `curl -Ls "https://get.maestro.mobile.dev" | bash` 3. Check the version number `maestro --version` ================================================ FILE: build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask @Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.protobuf) apply false alias(libs.plugins.mavenPublish) alias(libs.plugins.detekt) } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } tasks.named("compileKotlin", KotlinCompilationTask::class.java) { compilerOptions { freeCompilerArgs.addAll("-Xjdk-release=17") } } kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } detekt { buildUponDefaultConfig = true allRules = false autoCorrect = true config = files("${rootDir}/detekt.yml") } ================================================ FILE: detekt.yml ================================================ build: maxIssues: 0 excludeCorrectable: false weights: # complexity: 2 # LongParameterList: 1 # style: 1 # comments: 1 config: validation: true warningsAsErrors: false # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' excludes: '' processors: active: true exclude: - 'DetektProgressListener' # - 'KtFileCountProcessor' # - 'PackageCountProcessor' # - 'ClassCountProcessor' # - 'FunctionCountProcessor' # - 'PropertyCountProcessor' # - 'ProjectComplexityProcessor' # - 'ProjectCognitiveComplexityProcessor' # - 'ProjectLLOCProcessor' # - 'ProjectCLOCProcessor' # - 'ProjectLOCProcessor' # - 'ProjectSLOCProcessor' # - 'LicenseHeaderLoaderExtension' console-reports: active: true exclude: - 'ProjectStatisticsReport' - 'ComplexityReport' - 'NotificationReport' # - 'FindingsReport' - 'FileBasedFindingsReport' - 'LiteFindingsReport' output-reports: active: true exclude: # - 'TxtOutputReport' # - 'XmlOutputReport' # - 'HtmlOutputReport' comments: active: true AbsentOrWrongFileLicense: active: false licenseTemplateFile: 'license.template' licenseTemplateIsRegex: false CommentOverPrivateFunction: active: false CommentOverPrivateProperty: active: false DeprecatedBlockTag: active: false EndOfSentenceFormat: active: false endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' OutdatedDocumentation: active: false matchTypeParameters: true matchDeclarationsOrder: true UndocumentedPublicClass: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true UndocumentedPublicFunction: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UndocumentedPublicProperty: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] complexity: active: true ComplexCondition: active: true threshold: 4 ComplexInterface: active: false threshold: 10 includeStaticDeclarations: false includePrivateDeclarations: false ComplexMethod: active: true threshold: 15 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false nestingFunctions: - 'also' - 'apply' - 'forEach' - 'isNotNull' - 'ifNull' - 'let' - 'run' - 'use' - 'with' LabeledExpression: active: false ignoredLabels: [ ] LargeClass: active: true threshold: 600 excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] LongMethod: active: true threshold: 120 LongParameterList: active: false functionThreshold: 6 constructorThreshold: 7 ignoreDefaultParameters: false ignoreDataClasses: true ignoreAnnotatedParameter: [ ] MethodOverloading: active: false threshold: 6 NamedArguments: active: false threshold: 3 NestedBlockDepth: active: true threshold: 4 ReplaceSafeCallChainWithRun: active: false StringLiteralDuplication: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] thresholdInFiles: 11 thresholdInClasses: 11 thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 ignoreDeprecated: false ignorePrivate: false ignoreOverridden: false coroutines: active: true GlobalCoroutineUsage: active: false InjectDispatcher: active: false dispatcherNames: - 'IO' - 'Default' - 'Unconfined' RedundantSuspendModifier: active: false SleepInsteadOfDelay: active: false SuspendFunWithFlowReturnType: active: false empty-blocks: active: true EmptyCatchBlock: active: true allowedExceptionNameRegex: '_|(ignore|expected).*' EmptyClassBlock: active: true EmptyDefaultConstructor: active: true EmptyDoWhileBlock: active: true EmptyElseBlock: active: true EmptyFinallyBlock: active: true EmptyForBlock: active: true EmptyFunctionBlock: active: true ignoreOverridden: true EmptyIfBlock: active: true EmptyInitBlock: active: true EmptyKtFile: active: true EmptySecondaryConstructor: active: true EmptyTryBlock: active: true EmptyWhenBlock: active: true EmptyWhileBlock: active: true exceptions: active: true ExceptionRaisedInUnexpectedLocation: active: true methodNames: - 'equals' - 'finalize' - 'hashCode' - 'toString' InstanceOfCheckForException: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] NotImplementedDeclaration: active: false ObjectExtendsThrowable: active: false PrintStackTrace: active: true RethrowCaughtException: active: true ReturnFromFinally: active: true ignoreLabeled: false SwallowedException: active: true ignoredExceptionTypes: - 'InterruptedException' - 'MalformedURLException' - 'NumberFormatException' - 'ParseException' allowedExceptionNameRegex: '_|(ignore|expected).*' ThrowingExceptionFromFinally: active: true ThrowingExceptionInMain: active: false ThrowingExceptionsWithoutMessageOrCause: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Exception' - 'IllegalArgumentException' - 'IllegalMonitorStateException' - 'IllegalStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' ThrowingNewInstanceOfSameException: active: true TooGenericExceptionCaught: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' - 'Exception' - 'IllegalMonitorStateException' - 'IndexOutOfBoundsException' - 'NullPointerException' - 'RuntimeException' - 'Throwable' allowedExceptionNameRegex: '_|(ignore|expected).*' TooGenericExceptionThrown: active: true exceptionNames: - 'Error' - 'Exception' - 'RuntimeException' - 'Throwable' formatting: active: true android: false autoCorrect: true AnnotationOnSeparateLine: active: false autoCorrect: true AnnotationSpacing: active: false autoCorrect: true ArgumentListWrapping: active: false autoCorrect: true indentSize: 4 maxLineLength: 160 ChainWrapping: active: true autoCorrect: true CommentSpacing: active: true autoCorrect: true EnumEntryNameCase: active: false autoCorrect: true Filename: active: true FinalNewline: active: true autoCorrect: true insertFinalNewLine: true ImportOrdering: active: true autoCorrect: true layout: '*,java.**,javax.**,kotlin.**,^' Indentation: active: true autoCorrect: true indentSize: 4 continuationIndentSize: 4 MaximumLineLength: active: true maxLineLength: 160 ignoreBackTickedIdentifier: false ModifierOrdering: active: true autoCorrect: true MultiLineIfElse: active: false autoCorrect: true NoBlankLineBeforeRbrace: active: true autoCorrect: true NoConsecutiveBlankLines: active: true autoCorrect: true NoEmptyClassBody: active: true autoCorrect: true NoEmptyFirstLineInMethodBlock: active: false autoCorrect: true NoLineBreakAfterElse: active: true autoCorrect: true NoLineBreakBeforeAssignment: active: true autoCorrect: true NoMultipleSpaces: active: true autoCorrect: true NoSemicolons: active: true autoCorrect: true NoTrailingSpaces: active: true autoCorrect: true NoUnitReturn: active: true autoCorrect: true NoUnusedImports: active: true autoCorrect: true NoWildcardImports: active: true PackageName: active: false autoCorrect: true ParameterListWrapping: active: true autoCorrect: true indentSize: 4 maxLineLength: 160 SpacingAroundAngleBrackets: active: false autoCorrect: true SpacingAroundColon: active: true autoCorrect: true SpacingAroundComma: active: true autoCorrect: true SpacingAroundCurly: active: true autoCorrect: true SpacingAroundDot: active: true autoCorrect: true SpacingAroundDoubleColon: active: false autoCorrect: true SpacingAroundKeyword: active: true autoCorrect: true SpacingAroundOperators: active: true autoCorrect: true SpacingAroundParens: active: true autoCorrect: true SpacingAroundRangeOperator: active: true autoCorrect: true SpacingAroundUnaryOperator: active: false autoCorrect: true SpacingBetweenDeclarationsWithAnnotations: active: false autoCorrect: true SpacingBetweenDeclarationsWithComments: active: false autoCorrect: true StringTemplate: active: true autoCorrect: true naming: active: true BooleanPropertyNaming: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] allowedPattern: '^(is|has|are)' ClassNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] classPattern: '[A-Z][a-zA-Z0-9]*' ConstructorParameterNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] parameterPattern: '[a-z][A-Za-z0-9]*' privateParameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true EnumNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] forbiddenName: [ ] FunctionMaxLength: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] maximumFunctionNameLength: 30 FunctionMinLength: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] minimumFunctionNameLength: 3 FunctionNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' excludeClassPattern: '$^' ignoreOverridden: true FunctionParameterNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] parameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true InvalidPackageDeclaration: active: false rootPackage: '' LambdaParameterNaming: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] parameterPattern: '[a-z][A-Za-z0-9]*|_' MatchingDeclarationName: active: true mustBeFirst: true MemberNameEqualsClassName: active: true ignoreOverridden: true NoNameShadowing: active: false NonBooleanPropertyPrefixedWithIs: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] ObjectPropertyNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] constantPattern: '[A-Z][_A-Z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] maximumVariableNameLength: 64 VariableMinLength: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] minimumVariableNameLength: 1 VariableNaming: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' ignoreOverridden: true performance: active: true ArrayPrimitive: active: true ForEachOnRange: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] SpreadOperator: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UnnecessaryTemporaryInstantiation: active: true potential-bugs: active: true AvoidReferentialEquality: active: false forbiddenTypePatterns: - 'kotlin.String' CastToNullableType: active: false Deprecation: active: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: active: false DuplicateCaseInWhenExpression: active: true EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: active: true ExitOutsideMain: active: false ExplicitGarbageCollectionCall: active: true HasPlatformType: active: false IgnoredReturnValue: active: false restrictToAnnotatedMethods: true returnValueAnnotations: - '*.CheckResult' - '*.CheckReturnValue' ignoreReturnValueAnnotations: - '*.CanIgnoreReturnValue' ImplicitDefaultLocale: active: true ImplicitUnitReturnType: active: false allowExplicitReturnType: true InvalidRange: active: true IteratorHasNextCallsNextMethod: active: true IteratorNotThrowingNoSuchElementException: active: true LateinitUsage: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: active: false MissingPackageDeclaration: active: false excludes: [ '**/*.kts' ] MissingWhenCase: active: true allowElseExpression: true NullableToStringCall: active: false RedundantElseInWhen: active: true UnconditionalJumpStatementInLoop: active: false UnnecessaryNotNullOperator: active: true UnnecessarySafeCall: active: true UnreachableCatchBlock: active: false UnreachableCode: active: true UnsafeCallOnNullableType: active: true excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] UnsafeCast: active: true UnusedUnaryOperator: active: false UselessPostfixExpression: active: false WrongEqualsTypeParameter: active: true style: active: true ClassOrdering: active: false CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false conversionFunctionPrefix: 'to' DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: active: false maxDestructuringEntries: 3 EqualsNullCall: active: true EqualsOnSignatureLine: active: false ExplicitCollectionElementAccessMethod: active: false ExplicitItLambdaParameter: active: false ExpressionBodySyntax: active: false includeLineWrapping: false ForbiddenComment: active: true values: - 'FIXME:' - 'STOPSHIP:' - 'TODO:' allowedPatterns: '' customMessage: '' ForbiddenImport: active: false imports: [ ] forbiddenPatterns: '' ForbiddenMethodCall: active: false methods: - 'kotlin.io.print' - 'kotlin.io.println' ForbiddenPublicDataClass: active: true excludes: [ '**' ] ignorePackages: - '*.internal' - '*.internal.*' ForbiddenVoid: active: false ignoreOverridden: false ignoreUsageInGenerics: false FunctionOnlyReturningConstant: active: true ignoreOverridableFunction: true ignoreActualFunction: true excludedFunctions: '' LibraryCodeMustSpecifyReturnType: active: true excludes: [ '**' ] LibraryEntitiesShouldNotBePublic: active: true excludes: [ '**' ] LoopWithTooManyJumpStatements: active: true maxJumpCount: 1 MagicNumber: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] ignoreNumbers: - '-1' - '0' - '1' - '2' ignoreHashCodeFunction: true ignorePropertyDeclaration: false ignoreLocalVariableDeclaration: false ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true MandatoryBracesIfStatements: active: false MandatoryBracesLoops: active: false MaxLineLength: active: true maxLineLength: 160 excludePackageStatements: true excludeImportStatements: true excludeCommentStatements: false MayBeConst: active: true ModifierOrder: active: true MultilineLambdaItParameter: active: false NestedClassesVisibility: active: true NewLineAtEndOfFile: active: true NoTabs: active: false ObjectLiteralToLambda: active: false OptionalAbstractKeyword: active: true OptionalUnit: active: false OptionalWhenBraces: active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: active: true RedundantExplicitType: active: false RedundantHigherOrderMapUsage: active: false RedundantVisibilityModifierRule: active: false ReturnCount: active: false max: 2 excludedFunctions: 'equals' excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false SafeCast: active: true SerialVersionUIDInSerializableClass: active: true SpacingBetweenPackageAndImports: active: false ThrowsCount: active: false max: 2 excludeGuardClauses: false TrailingWhitespace: active: false UnderscoresInNumericLiterals: active: false acceptableLength: 4 UnnecessaryAbstractClass: active: false UnnecessaryAnnotationUseSiteTarget: active: false UnnecessaryApply: active: true UnnecessaryFilter: active: false UnnecessaryInheritance: active: true UnnecessaryLet: active: false UnnecessaryParentheses: active: false UntilInsteadOfRangeTo: active: false UnusedImports: active: false UnusedPrivateClass: active: true UnusedPrivateMember: active: true allowedNames: '(_|ignored|expected|serialVersionUID)' UseAnyOrNoneInsteadOfFind: active: false UseArrayLiteralsInAnnotations: active: false UseCheckNotNull: active: false UseCheckOrError: active: false UseDataClass: active: false allowVars: false UseEmptyCounterpart: active: false UseIfEmptyOrIfBlank: active: false UseIfInsteadOfWhen: active: false UseIsNullOrEmpty: active: false UseOrEmpty: active: false UseRequire: active: false UseRequireNotNull: active: false UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: active: true VarCouldBeVal: active: true WildcardImport: active: false excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] excludeImports: - 'java.util.*' ================================================ FILE: e2e/.gitignore ================================================ apps/ samples/ samples.zip ================================================ FILE: e2e/README.md ================================================ # e2e This directory contains glue code for testing Maestro itself. ## Testing Typical workflow is: 1. Start Android emulator and iOS simulator 2. `download_apps` 3. `install_apps` 4. `run_tests` We try to keep shell code in separate files, so we don't get too tightly coupled to GitHub Actions. ### Expected failures Let's say a critical bug is introduced that causes Maestro to always mark all tests as passed. If our e2e test suite only was only checking if all tests pass (i.e. `maestro test` exit code is 0), then wouldn't catch such a bug. To prevent this, all flows in this directory MUST have a `passing` or `failing` label, so the correct outcome can be asserted. ## Samples This directory also contains samples that are downloaded by the `maestro download-samples` command, and some glue code to facilitate updating those samples. `maestro download-samples` provides a set of flows and apps so that users can quickly try out Maestro, without having to write any flows for their own app. `download-samples` downloads these files and apps from our publicly-available Google Cloud Storage bucket (hosted on `storage.googleapis.com`). ### Intro The samples are automatically updated by the GitHub Action on every new commit to the `main` branch. There zip archive that is downloaded by `download-samples` consists of 2 things: - the Maestro workspace with flows (located in the `workspaces/wikipedia` directory) - the app binary files that are used in the flows (located in the `apps` directory) App binary files are heavy, so we don't store them in the repository. Instead, they are hosted on publicly available directory in Google Cloud Storage: ### Update the samples Run the script: ```console ./update_samples ``` ================================================ FILE: e2e/download_apps ================================================ #!/usr/bin/env sh set -eu # Download apps from URLs listed in manifest.txt. # # We assume that if the downloaded file is a zip file, it's an iOS app and must # be unzipped. [ "$(basename "$PWD")" = "e2e" ] || { echo "must be run from e2e directory" && exit 1; } command -v curl >/dev/null 2>&1 || { echo "curl is required" && exit 1; } platform="${1:-}" # android or ios or an empty string (no filter) platform="$(echo "$platform" | tr '[:upper:]' '[:lower:]')" # Normalize to lowercase mkdir -p ./apps while read -r url; do case "$platform" in android) echo "$url" | grep -qi '\.apk$' || continue # Skip if not an APK ;; ios) echo "$url" | grep -qi '\.zip$' || continue # Skip if not a ZIP file ;; *) # No filter ;; esac echo "download $url" app_file="$(curl -fsSL --output-dir ./apps --write-out "%{filename_effective}" -OJ "$url")" extension="${app_file##*.}" if [ "$extension" = "zip" ]; then unzip -qq -o -d ./apps "$app_file" -x "__MACOSX/*" fi done " exit 1 fi command -v adb >/dev/null 2>&1 || { echo "adb is required" && exit 1; } for file in ./apps/*; do filename="$(basename "$file")" extension="${file##*.}" if [ "$platform" = android ] && [ "$extension" = "apk" ]; then echo "install $filename" adb install -r "$file" >/dev/null || echo "adb: could not install $filename" elif [ "$platform" = ios ] && [ "$extension" = "app" ] && [ "$(uname)" = "Darwin" ]; then echo "install $filename" xcrun simctl install booted "$file" || echo "xcrun: could not install $filename" fi done ================================================ FILE: e2e/manifest.txt ================================================ https://storage.googleapis.com/mobile.dev/cli_e2e/wikipedia.apk https://storage.googleapis.com/mobile.dev/cli_e2e/wikipedia.zip https://storage.googleapis.com/mobile.dev/cli_e2e/demo_app.apk https://storage.googleapis.com/mobile.dev/cli_e2e/demo_app.zip https://storage.googleapis.com/mobile.dev/cli_e2e/setOrientation.apk https://storage.googleapis.com/mobile.dev/cli_e2e/SimpleWebViewApp.zip ================================================ FILE: e2e/run_tests ================================================ #!/usr/bin/env sh set -eu # Runs all tests in the workspaces directory. command -v maestro >/dev/null 2>&1 || { echo "maestro is required" && exit 1; } [ "$(basename "$PWD")" = "e2e" ] || { echo "must be run from e2e directory" && exit 1; } ALL_PASS=true _h1() { printf "=>\n=> %s\n=>\n" "$1"; } _h2() { printf "==> [%s] %s\n" "$1" "$2"; } _h3() { printf "==> [%s] [%s] => %s\n" "$1" "$2" "$3"; } cloud="android_device_configuration,ios_device_configuration" # Maestro Cloud specific tests platform="${1:-}" case "$platform" in android) exclude_tags="ios,web,$cloud" ;; ios) exclude_tags="android,$cloud,web" ;; web) exclude_tags="android,ios,$cloud" ;; *) echo "usage: $0 "; exit 1 ;; esac mkfifo pipe trap 'rm -f pipe' EXIT export MAESTRO_EXAMPLE="test-value" # Relied upon in a test if [ "$platform" = "web" ]; then ### ### Web: run web-tagged tests for demo_app only ### workspace_dir="./workspaces/demo_app" app_name="demo_app" _h1 "run tests for app \"$app_name\" on platform \"$platform\"" _h2 "$app_name" "run web tests" while IFS= read -r line; do _h3 "$app_name" "web" "$line" done < pipe & maestro --verbose --platform "$platform" test --headless --include-tags web --exclude-tags "$exclude_tags" "$workspace_dir" 1>pipe 2>&1 \ || { _h2 "$app_name" "FAIL! Expected all pass, but at least some failed instead"; ALL_PASS=false; } else for workspace_dir in ./workspaces/*; do app_name="$(basename "$workspace_dir")" case $app_name in # demo_app has OOM issues on GHA demo_app|setOrientation) [ "$platform" = "ios" ] && continue ;; simple_web_view) [ "$platform" = "android" ] && continue ;; esac _h1 "run tests for app \"$app_name\" on platform \"$platform\"" ### ### Run passing tests ### _h2 "$app_name" "run passing tests" while IFS= read -r line; do _h3 "$app_name" "passing" "$line" done < pipe & maestro --verbose --platform "$platform" test --include-tags passing --exclude-tags "$exclude_tags" "$workspace_dir" 1>pipe 2>&1 \ || { _h2 "$app_name" "FAIL! Expected all pass, but at least some failed instead"; ALL_PASS=false; } ### ### Run failing tests (skip workspaces with no failing flows) ### case $app_name in wikipedia|setOrientation|simple_web_view) continue ;; esac _h2 "$app_name" "run failing tests" while IFS= read -r line; do _h3 "$app_name" "failing" "$line" done < pipe & maestro --verbose --platform "$platform" test --include-tags failing --exclude-tags "$exclude_tags" "$workspace_dir" 1>pipe 2>&1 \ && { _h2 "$app_name" "FAIL! Expected all to fail, but at least some passed instead"; ALL_PASS=false; } done fi if [ "$ALL_PASS" = "false" ]; then _h1 "FAILURE: some tests failed!" exit 1 else _h1 "SUCCESS: all tests passed!" fi ================================================ FILE: e2e/update_samples ================================================ #!/usr/bin/env sh set -eu # Updates the samples that are hosted in mobile.dev's GCS bucket ($SAMPLES_URL). # The samples are for use with `maestro download-samples` command. [ "$(basename "$PWD")" = "e2e" ] || { echo "must be run from e2e directory" && exit 1; } command -v curl >/dev/null 2>&1 || { echo "curl is required" && exit 1; } command -v gsutil >/dev/null 2>&1 || { echo "gsutil is required" && exit 1; } SAMPLES_URL="gs://mobile.dev/samples/samples.zip" if [ ! -d apps/ ]; then ./download_apps fi rm -rf samples/ samples.zip mkdir -p samples/ cp -r apps/wikipedia.apk apps/wikipedia.zip samples/ cp -r workspaces/wikipedia/* samples/ cp samples/wikipedia.apk samples/sample.apk # The name is being depended upon. cp samples/wikipedia.zip samples/sample.zip # The name is being depended upon. cd samples/ zip -r -q ../samples.zip . -x "/**/.*" -x "__MACOSX" cd .. gsutil cp samples.zip "$SAMPLES_URL" gsutil acl ch -r -u AllUsers:R "$SAMPLES_URL" ================================================ FILE: e2e/workspaces/setOrientation/test-set-orientation-flow.yaml ================================================ appId: com.example.maestro.orientation env: orientationLandscapeLeft: LANDSCAPE_LEFT orientationLandscapeRight: LANDSCAPE_RIGHT orientationUpsideDown: UPSIDE_DOWN orientationPortrait: PORTRAIT tags: - android - passing --- - launchApp - setOrientation: LANDSCAPE_LEFT - assertVisible: "LANDSCAPE_LEFT" - setOrientation: LANDSCAPE_RIGHT - assertVisible: "LANDSCAPE_RIGHT" - setOrientation: UPSIDE_DOWN - assertVisible: "UPSIDE_DOWN" - setOrientation: PORTRAIT - assertVisible: "PORTRAIT" - setOrientation: ${orientationLandscapeLeft} - assertVisible: "LANDSCAPE_LEFT" - setOrientation: ${orientationLandscapeRight} - assertVisible: "LANDSCAPE_RIGHT" - setOrientation: ${orientationUpsideDown} - assertVisible: "UPSIDE_DOWN" - setOrientation: ${orientationPortrait} - assertVisible: "PORTRAIT" ================================================ FILE: e2e/workspaces/simple_web_view/webview.yaml ================================================ appId: com.example.SimpleWebViewApp tags: - passing - ios --- - launchApp: clearState: true - tapOn: Open Login Page - extendedWaitUntil: visible: Login timeout: 30000 label: Wait for Login page to load - assertVisible: Sign In - assertVisible: Forgot your password? ================================================ FILE: e2e/workspaces/wikipedia/android-advanced-flow.yaml ================================================ appId: org.wikipedia tags: - android - passing - advanced --- - runFlow: subflows/onboarding-android.yaml - tapOn: id: "org.wikipedia:id/search_container" - tapOn: text: "Non existent view" optional: true - runScript: scripts/getSearchQuery.js - inputText: ${output.result} - assertVisible: ${output.result} - runFlow: subflows/launch-clearstate-android.yaml ================================================ FILE: e2e/workspaces/wikipedia/android-flow.yaml ================================================ appId: org.wikipedia tags: - android - passing --- - launchApp ================================================ FILE: e2e/workspaces/wikipedia/ios-advanced-flow.yaml ================================================ appId: org.wikimedia.wikipedia tags: - ios - passing - advanced --- - runFlow: subflows/onboarding-ios.yaml - runFlow: when: visible: text: Explore your Wikipedia Year in Review commands: - tapOn: Done label: Dismiss Year In Review popup, if visible - runFlow: when: visible: "You have been logged out" commands: - tapOn: text: "Continue without logging in" label: Dismiss the auth modal if visible - tapOn: text: "Non existent view" optional: true - tapOn: Search Wikipedia - runScript: scripts/getSearchQuery.js - inputText: ${output.result} - eraseText - inputText: qwerty - assertVisible: ${output.result} - runFlow: subflows/launch-clearstate-ios.yaml ================================================ FILE: e2e/workspaces/wikipedia/ios-flow.yaml ================================================ appId: org.wikimedia.wikipedia tags: - ios - passing --- - launchApp ================================================ FILE: e2e/workspaces/wikipedia/scripts/getSearchQuery.js ================================================ output.result = 'qwerty'; ================================================ FILE: e2e/workspaces/wikipedia/subflows/launch-clearstate-android.yaml ================================================ appId: org.wikipedia --- - launchApp: clearState: true - assertVisible: "Continue" - assertVisible: "Skip" ================================================ FILE: e2e/workspaces/wikipedia/subflows/launch-clearstate-ios.yaml ================================================ appId: org.wikimedia.wikipedia --- - launchApp: clearState: true - assertVisible: "Next" - assertVisible: "Skip" ================================================ FILE: e2e/workspaces/wikipedia/subflows/onboarding-android.yaml ================================================ appId: org.wikipedia --- - launchApp: clearState: true - tapOn: text: "Non existent view" optional: true - tapOn: id: "org.wikipedia:id/fragment_onboarding_forward_button" - tapOn: id: "org.wikipedia:id/fragment_onboarding_forward_button" - tapOn: id: "org.wikipedia:id/fragment_onboarding_forward_button" - tapOn: id: "org.wikipedia:id/fragment_onboarding_done_button" ================================================ FILE: e2e/workspaces/wikipedia/subflows/onboarding-ios.yaml ================================================ appId: org.wikimedia.wikipedia --- - launchApp: clearState: true - repeat: times: 3 commands: - swipe: direction: LEFT duration: 400 - waitForAnimationToEnd - tapOn: Get started - tapOn: text: "Non existent view" optional: true ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/login.yml ================================================ appId: org.wikipedia --- - tapOn: "More" - tapOn: "LOG IN.*" - tapOn: id: ".*create_account_login_button" - runScript: "../scripts/fetchTestUser.js" - tapOn: "Username" - inputText: "${output.test_user.username}" - tapOn: "Password" - inputText: "No provided" - tapOn: "LOG IN" - back - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/signup.yml ================================================ appId: org.wikipedia --- - tapOn: "More" - tapOn: "LOG IN.*" - runScript: "../scripts/generateCredentials.js" - tapOn: "Username" - inputText: "${output.credentials.username}" - tapOn: "Password" - inputText: "${output.credentials.password}" - tapOn: "Repeat password" - inputText: "${output.credentials.password}" - tapOn: "Email.*" - inputText: "${output.credentials.email}" # We won't actually create the account - back - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/copy-paste.yml ================================================ appId: org.wikipedia --- - tapOn: "Explore" - scrollUntilVisible: element: "Top read" - copyTextFrom: id: ".*view_list_card_item_title" index: 0 - tapOn: "Explore" - tapOn: "Search Wikipedia" - inputText: "${maestro.copiedText}" - back - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/feed.yml ================================================ appId: org.wikipedia --- - tapOn: "Explore" - scrollUntilVisible: element: "Today on Wikipedia.*" - tapOn: "Today on Wikipedia.*" - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/main.yml ================================================ appId: org.wikipedia --- - runFlow: "search.yml" - runFlow: "saved.yml" - runFlow: "feed.yml" - runFlow: "copy-paste.yml" ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/saved.yml ================================================ appId: org.wikipedia --- - tapOn: "Saved" - tapOn: "Default list for your saved articles" - assertVisible: "Sun" - assertVisible: "Star at the center of the Solar System" - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/search.yml ================================================ appId: org.wikipedia --- - tapOn: "Search Wikipedia" - inputText: "Sun" - assertVisible: "Star at the center of the Solar System" - tapOn: id: ".*page_list_item_title" - tapOn: id: ".*page_save" - back - back - back ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/add-language.yml ================================================ appId: org.wikipedia --- - tapOn: "ADD OR EDIT.*" - tapOn: "ADD LANGUAGE" - tapOn: id: ".*menu_search_language" - inputText: "Greek" - assertVisible: "Ελληνικά" - tapOn: "Ελληνικά" - tapOn: "Navigate up" ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/main.yml ================================================ appId: org.wikipedia --- - runFlow: "add-language.yml" - runFlow: "remove-language.yml" - tapOn: "Continue" - assertVisible: "New ways to explore" - tapOn: "Continue" - assertVisible: "Reading lists with sync" - tapOn: "Continue" - assertVisible: "Send anonymous data" - tapOn: "Get started" ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/remove-language.yml ================================================ appId: org.wikipedia --- - tapOn: "ADD OR EDIT.*" - tapOn: "More options" - tapOn: "Remove language" - tapOn: id: ".*wiki_language_checkbox" index: 1 - tapOn: id: ".*menu_delete_selected" - tapOn: "OK" - assertNotVisible: "Ελληνικά" - tapOn: "Navigate up" ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/run-test.yml ================================================ appId: org.wikipedia tags: - android - passing --- - launchApp: clearState: true - runFlow: "onboarding/main.yml" - runFlow: "dashboard/main.yml" - runFlow: "auth/signup.yml" - runFlow: "auth/login.yml" ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/scripts/fetchTestUser.js ================================================ // Fetches test user from API function getTestUserFromApi() { const url = `https://jsonplaceholder.typicode.com/users/1`; var response = http.get(url); var data = json(response.body); return { username: data.username, email: data.email, }; } output.test_user = getTestUserFromApi(); ================================================ FILE: e2e/workspaces/wikipedia/wikipedia-android-advanced/scripts/generateCredentials.js ================================================ function username() { var date = new Date().getTime().toString(); var username = `test_user_placeholder`.replace("placeholder", date); return username; } function email() { var date = new Date().getTime().toString(); var email = `test-user-placeholder@test.com`.replace("placeholder", date); return email; } function password() { var date = new Date().getTime().toString(); var password = `test-user-password-placeholder`.replace("placeholder", date); return password; } output.credentials = { email: email(), password: password(), username: username(), }; ================================================ FILE: gradle/libs.versions.toml ================================================ # File should be sorted by alphabet for each section # How to sort with AS: # "Select all in block" -> "Edit" -> "Sort lines" # File should follow the naming convention: # https://blog.gradle.org/best-practices-naming-version-catalog-entries [versions] androidPlugin = "8.13.2" androidxEspresso = "3.6.1" androidxTestJunit = "1.2.1" androidxUiautomator = "2.3.0" apkParser = "2.6.10" appdirs = "1.2.1" axml = "2.1.2" commons-codec = "1.17.0" commons-lang3 = "3.13.0" # 3.14.0 causes weird crashes during dexing commons-io = "2.16.1" dadb = "1.2.10" datafaker = "2.5.3" ddPlist = "1.23" detekt = "1.23.8" googleFindbugs = "3.0.2" googleGson = "2.11.0" googleProtobuf = "3.21.9" googleProtobufPlugin = "0.9.4" googleTruth = "1.4.2" graaljs = "24.2.0" grpc = "1.50.2" grpcKotlinStub = "1.4.1" imageComparison = "4.4.0" hiddenapibypass = "4.3" jackson = "2.17.1" jansi = "2.4.1" jarchivelib = "1.2.0" jcodec = "0.2.5" junit = "5.10.2" kotlin = "2.2.0" kotlinRetry = "2.0.1" kotlinResult = "2.0.1" kotlinx-serialization-json = "1.5.0" ktor = "2.3.6" micrometerObservation = "1.13.4" micrometerCore = "1.13.4" mockk = "1.12.0" mordant = "3.0.2" mozillaRhino = "1.7.14" picocli = "4.6.3" posthog = "1.0.3" selenium = "4.40.0" selenium-devtools = "4.40.0" skiko = "0.8.18" squareOkhttp = "4.12.0" squareOkio = "3.16.2" squareMockWebServer = "4.11.0" wiremock = "2.35.0" log4j = "2.25.3" coroutines = "1.8.0" kotlinx-html = "0.8.0" clikt = "4.2.2" gmsLocation = "21.3.0" mcpKotlinSdk = "steviec/kotlin-1.8" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidxUiautomator" } apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apkParser" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } axml = { module = "de.upb.cs.swt:axml", version.ref = "axml" } commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" } commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } dadb = { module = "dev.mobile:dadb", version.ref = "dadb" } datafaker = { module = "net.datafaker:datafaker", version.ref = "datafaker" } dd-plist = { module = "com.googlecode.plist:dd-plist", version.ref = "ddPlist" } google-findbugs = { module = "com.google.code.findbugs:jsr305", version.ref = "googleFindbugs" } google-gson = { module = "com.google.code.gson:gson", version.ref = "googleGson" } google-protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "googleProtobuf" } google-protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "googleProtobuf" } google-truth = { module = "com.google.truth:truth", version.ref = "googleTruth" } graaljs = { module = "org.graalvm.js:js", version.ref = "graaljs" } graaljsEngine = { module = "org.graalvm.js:js-scriptengine", version.ref = "graaljs" } graaljsLanguage = { module = "org.graalvm.js:js-language", version.ref = "graaljs" } grpc-kotlin-stub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpcKotlinStub" } grpc-netty = { module = "io.grpc:grpc-netty", version.ref = "grpc" } grpc-netty-shaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc" } grpc-okhttp = { module = "io.grpc:grpc-okhttp", version.ref = "grpc" } grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } grpc-protobuf-lite = { module = "io.grpc:grpc-protobuf-lite", version.ref = "grpc" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } image-comparison = { module = "com.github.romankh3:image-comparison", version.ref = "imageComparison" } jackson-core-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-dataformat-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } jansi = { module = "org.fusesource.jansi:jansi", version.ref = "jansi" } jarchivelib = { module = "org.rauschig:jarchivelib", version.ref = "jarchivelib" } jcodec = { module = "org.jcodec:jcodec", version.ref = "jcodec" } jcodec-awt = { module = "org.jcodec:jcodec-javase", version.ref = "jcodec" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlinResult" } kotlin-retry = { module = "com.michael-bull.kotlin-retry:kotlin-retry", version.ref = "kotlinRetry" } clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-serial-gson = { module = "io.ktor:ktor-serialization-gson", version.ref = "ktor" } ktor-serial-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometerCore" } micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "micrometerObservation" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mordant = { module = "com.github.ajalt.mordant:mordant", version.ref = "mordant" } mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "mozillaRhino" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" } posthog = { module = "com.posthog:posthog-server", version.ref = "posthog" } selenium = { module = "org.seleniumhq.selenium:selenium-java", version.ref = "selenium" } selenium-devtools = { module = "org.seleniumhq.selenium:selenium-devtools-v142", version.ref = "selenium-devtools" } skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" } skiko-macos-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-x64", version.ref = "skiko" } skiko-linux-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-linux-arm64", version.ref = "skiko" } skiko-linux-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-linux-x64", version.ref = "skiko" } skiko-windows-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-windows-arm64", version.ref = "skiko" } skiko-windows-x64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-windows-x64", version.ref = "skiko" } square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareOkhttp" } square-okhttp-logs = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "squareOkhttp" } square-okio = { module = "com.squareup.okio:okio", version.ref = "squareOkio" } square-okio-jvm = { module = "com.squareup.okio:okio-jvm", version.ref = "squareOkio" } square-mock-server = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "squareMockWebServer" } wiremock-jre8 = { module = "com.github.tomakehurst:wiremock-jre8", version.ref = "wiremock" } logging-sl4j = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } logging-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } logging-layout-template = { module = "org.apache.logging.log4j:log4j-layout-template-json", version.ref = "log4j" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } gmsLocation = { module = "com.google.android.gms:play-services-location", version.ref = "gmsLocation" } mcp-kotlin-sdk = { module = "io.modelcontextprotocol:kotlin-sdk" } [bundles] [plugins] android-application = { id = "com.android.application", version.ref = "androidPlugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } protobuf = { id = "com.google.protobuf", version.ref = "googleProtobufPlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.33.0" } jreleaser = { id = "org.jreleaser", version = "1.13.1" } shadow = { id = "com.github.johnrengelman.shadow", version = "7.1.2" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official GROUP=dev.mobile VERSION_NAME=2.4.0 POM_DESCRIPTION=Maestro is a server-driven platform-agnostic library that allows to drive tests for both iOS and Android using the same implementation through an intuitive API. POM_URL=https://github.com/mobile-dev-inc/maestro POM_SCM_URL=https://github.com/mobile-dev-inc/maestro POM_SCM_CONNECTION=scm:git:git://github.com/mobile-dev-inc/maestro.git POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/mobile-dev-inc/maestro.git POM_LICENCE_NAME=The Apache Software License, Version 2.0 POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt POM_LICENCE_DIST=repo POM_DEVELOPER_ID=mobile-dev-inc POM_DEVELOPER_NAME=mobile.dev inc. SONATYPE_STAGING_PROFILE=dev.mobile org.gradle.jvmargs=-Xmx2000m ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: installLocally.sh ================================================ #!/bin/sh ./gradlew :maestro-cli:installDist rm -rf ~/.maestro/bin rm -rf ~/.maestro/lib cp -r ./maestro-cli/build/install/maestro/bin ~/.maestro/bin cp -r ./maestro-cli/build/install/maestro/lib ~/.maestro/lib ================================================ FILE: maestro ================================================ #!/usr/bin/env bash set -e if [ -t 0 ]; then input="" else input=$(cat -) fi ./gradlew :maestro-cli:installDist -q && ./maestro-cli/build/install/maestro/bin/maestro "$@" ================================================ FILE: maestro-ai/README.md ================================================ # maestro-ai This project implements AI support for use in Maestro. It's both a library and an executable demo app. ## Demo app An API key is required. Set it with `MAESTRO_CLI_AI_KEY` env var. Examples: - OpenAI: `export MAESTRO_CLI_AI_KEY=sk-...` - Anthropic: `export MAESTRO_CLI_AI_KEY=sk-ant-api-...` ### Build ```console ./gradlew :maestro-ai:installDist ``` The startup script will be generated in `./maestro-ai/build/install/maestro-ai-demo/bin/maestro-ai-demo`. ### How to use First of all, try out the `--help` flag. Run test for a single screenshot that contains defects (i.e. is bad): ```console maestro-ai-demo foo_1_bad.png ``` Run tests for all screenshots from the Uber that contain defects (i.e. are bad). Additionally, show prompts and raw LLM response: ```console maestro-ai-demo \ --model gpt-4o-2024-08-06 \ --show-prompts \ --show-raw-response \ test-ai-fixtures/uber_*_bad.png ``` ================================================ FILE: maestro-ai/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { application id("maven-publish") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.mavenPublish) } application { applicationName = "maestro-ai-demo" mainClass.set("maestro.ai.DemoAppKt") } mavenPublishing { publishToMavenCentral(true) signAllPublications() } tasks.named("jar") { manifest { attributes["Main-Class"] = "maestro.ai.DemoAppKt" } } dependencies { api(libs.kotlin.result) api(libs.square.okio) api(libs.square.okio.jvm) api(libs.square.okhttp) api(libs.logging.sl4j) api(libs.logging.api) api(libs.logging.layout.template) api(libs.log4j.core) api(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.serial.json) implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinx.coroutines.core) implementation(libs.clikt) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.google.truth) testImplementation(libs.square.mock.server) testImplementation(libs.junit.jupiter.params) } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } tasks.named("compileKotlin", KotlinCompilationTask::class.java) { compilerOptions { freeCompilerArgs.addAll("-Xjdk-release=17") } } ================================================ FILE: maestro-ai/gradle.properties ================================================ POM_NAME=Maestro AI POM_ARTIFACT_ID=maestro-ai POM_PACKAGING=jar ================================================ FILE: maestro-ai/src/main/java/maestro/ai/AI.kt ================================================ package maestro.ai import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import java.io.Closeable data class CompletionData( val prompt: String, val model: String, val temperature: Float, val maxTokens: Int, val images: List, val response: String, ) abstract class AI( val defaultModel: String, protected val httpClient: HttpClient, ) : Closeable { /** * Chat completion with the AI model. * * Caveats: * - `jsonSchema` is only supported by OpenAI ("Structured Outputs" feature) */ abstract suspend fun chatCompletion( prompt: String, images: List = listOf(), temperature: Float? = null, model: String? = null, maxTokens: Int? = null, imageDetail: String? = null, identifier: String? = null, jsonSchema: JsonObject? = null, ): CompletionData companion object { const val AI_KEY_ENV_VAR = "MAESTRO_CLI_AI_KEY" const val AI_MODEL_ENV_VAR = "MAESTRO_CLI_AI_MODEL" val defaultHttpClient = HttpClient { install(ContentNegotiation) { Json { ignoreUnknownKeys = true } } install(HttpTimeout) { connectTimeoutMillis = 10000 socketTimeoutMillis = 60000 requestTimeoutMillis = 60000 } } } } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/CloudPredictionAIEngine.kt ================================================ package maestro.ai import maestro.ai.cloud.Defect import maestro.ai.Prediction class CloudAIPredictionEngine(private val apiKey: String) : AIPredictionEngine { override suspend fun findDefects(screen: ByteArray): List { return Prediction.findDefects(apiKey, screen) } override suspend fun performAssertion(screen: ByteArray, assertion: String): Defect? { return Prediction.performAssertion(apiKey, screen, assertion) } override suspend fun extractText(screen: ByteArray, query: String): String { return Prediction.extractText(apiKey, query, screen) } } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/DemoApp.kt ================================================ package maestro.ai import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.float import com.github.ajalt.clikt.parameters.types.path import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import maestro.ai.anthropic.Claude import maestro.ai.cloud.Defect import maestro.ai.openai.OpenAI import java.io.File import java.nio.file.Path fun main(args: Array) = DemoApp().main(args) /** * This is a small helper program to help evaluate LLM results against a directory of screenshots and prompts. * * ### Input format * * Screenshot name format: * - {app_name}_{screenshot_number}_{good|bad}.png * * A screenshot can optionally have a prompt. In this case, the model will treat the prompt as the assertion command. * To associate a prompt with a screenshot, prompt text file name must have * the following format: * - {app_name_{screenshot_number}_{good|bad}.txt * * For example: * - foo_1_bad.png * - bar_2_good.png * * ### Output format * * The output for a single screenshot should indicate either PASS or FAIL, screenshot name, the result, and the defects * founds (if any). * * For example: * * ```text * PASS uber_2_bad.png: 1 defects found (as expected) * * layout: The prompt for entering a verification code is visible, indicating that the 2-factor authentication process is present. The screen instructs the user to enter a verification code generated for Uber, which is a typical 2-factor authentication step. * ``` * * Some of the flags change output format. */ class DemoApp : CliktCommand() { private val inputFiles: List by argument(help = "screenshots to use").path(mustExist = true).multiple() private val model: String by option(help = "LLM to use").default("gpt-4o") private val showOnlyFails: Boolean by option(help = "Show only failed tests").flag() private val showPrompts: Boolean by option(help = "Show prompts").flag() private val showRawResponse: Boolean by option(help = "Show raw LLM response").flag() private val temperature: Float by option(help = "Temperature for LLM").float().default(0.2f) private val parallel: Boolean by option(help = "Run in parallel. May get rate limited").flag() // IDEA: "--json" flag to allow for easy filtering with jq override fun run() = runBlocking { val apiKey = System.getenv("MAESTRO_CLI_AI_KEY") require(apiKey != null) { "OpenAI API key is not provided" } val testCases = inputFiles.map { it.toFile() }.map { file -> require(!file.isDirectory) { "Provided file is a directory, not a file" } require(file.exists()) { "Provided file does not exist" } require(file.extension == "png") { "Provided file is not a PNG file" } file }.map { file -> val filename = file.nameWithoutExtension val parts = filename.split("_") require(parts.size == 3) { "Screenshot name is invalid: ${file.name}" } val appName = parts[0] val index = parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid screenshot name: ${file.name}") val status = parts[2] val promptFile = "${file.parent}/${appName}_${index}_${status}.txt" val prompt = File(promptFile).run { if (exists()) { println("Found prompt file: $promptFile") readText() } else null } TestCase( screenshot = file, appName = appName, shouldPass = status == "good", index = index, prompt = prompt, ) }.toList() val aiClient: AI = when { model.startsWith("gpt") -> OpenAI( apiKey = apiKey, defaultModel = model, defaultTemperature = temperature, ) model.startsWith("claude") -> Claude( apiKey = apiKey, defaultModel = model, defaultTemperature = temperature, ) else -> throw IllegalArgumentException("Unknown model: $model") } val cloudApiKey = System.getenv("MAESTRO_CLOUD_API_KEY") if (cloudApiKey.isNullOrEmpty()) { throw IllegalArgumentException("`MAESTRO_CLOUD_API_KEY` is not available. Did you export MAESTRO_CLOUD_API_KEY?") } testCases.forEach { testCase -> val bytes = testCase.screenshot.readBytes() val job = async { val defects = if (testCase.prompt == null) Prediction.findDefects( apiKey = cloudApiKey, screen = bytes, ) else { val result = Prediction.performAssertion( apiKey = cloudApiKey, screen = bytes, assertion = testCase.prompt, ) if (result == null) emptyList() else listOf(result) } verify(testCase, defects) } if (!parallel) job.await() } } private fun verify(testCase: TestCase, defects: List) { if (!testCase.shouldPass) { // Check if LLM found defects (i.e. didn't commit false negative) if (defects.isNotEmpty()) { if (showOnlyFails) return println( """ PASS ${testCase.screenshot.name}: ${defects.size} defects found (as expected) ${defects.joinToString("\n") { "\t* ${it.category}: ${it.reasoning}" }} """.trimIndent() ) } else { println("FAIL ${testCase.screenshot.name} false-negative: No defects found but some were expected") } } else { // Check that LLM didn't raise false positives if (defects.isEmpty()) { if (showOnlyFails) return println( """ PASS ${testCase.screenshot.name}: No defects found (as expected) """.trimIndent() ) } else { println( """ FAIL ${testCase.screenshot.name} false-positive: ${defects.size} defects found but none were expected ${defects.joinToString("\n") { "\t* ${it.category}: ${it.reasoning}" }} """.trimIndent() ) } } } } data class TestCase( val screenshot: File, val appName: String, val prompt: String?, val shouldPass: Boolean, val index: Int, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/IAPredictionEngine.kt ================================================ package maestro.ai import maestro.ai.cloud.Defect interface AIPredictionEngine { suspend fun findDefects(screen: ByteArray): List suspend fun performAssertion(screen: ByteArray, assertion: String): Defect? suspend fun extractText(screen: ByteArray, query: String): String } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/Prediction.kt ================================================ package maestro.ai import maestro.ai.cloud.ApiClient import maestro.ai.cloud.Defect object Prediction { private val apiClient = ApiClient() suspend fun findDefects( apiKey: String, screen: ByteArray, ): List { val response = apiClient.findDefects(apiKey, screen) return response.defects } suspend fun performAssertion( apiKey: String, screen: ByteArray, assertion: String, ): Defect? { val response = apiClient.findDefects(apiKey, screen, assertion) return response.defects.firstOrNull() } suspend fun extractText( apiKey: String, query: String, screen: ByteArray, ): String { val response = apiClient.extractTextWithAi(apiKey, query, screen) return response.text } } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/anthropic/Client.kt ================================================ package maestro.ai.anthropic import Response import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.util.encodeBase64 import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import maestro.ai.AI import maestro.ai.CompletionData import org.slf4j.LoggerFactory private const val API_URL = "https://api.anthropic.com/v1/messages" private val logger = LoggerFactory.getLogger(Claude::class.java) class Claude( defaultModel: String = "claude-3-5-sonnet-20240620", httpClient: HttpClient = defaultHttpClient, private val apiKey: String, private val defaultTemperature: Float = 0.2f, private val defaultMaxTokens: Int = 1024, private val defaultImageDetail: String = "high", ) : AI(defaultModel = defaultModel, httpClient = httpClient) { private val json = Json { ignoreUnknownKeys = true } override suspend fun chatCompletion( prompt: String, images: List, temperature: Float?, model: String?, maxTokens: Int?, imageDetail: String?, identifier: String?, jsonSchema: JsonObject?, ): CompletionData { val imagesBase64 = images.map { it.encodeBase64() } // Fallback to Anthropic defaults val actualTemperature = temperature ?: defaultTemperature val actualModel = model ?: defaultModel val actualMaxTokens = maxTokens ?: defaultMaxTokens val actualImageDetail = imageDetail ?: defaultImageDetail val imageContents = imagesBase64 .map { imageBase64 -> Content( type = "image", source = ContentSource( type = "base64", mediaType = "image/png", data = imageBase64, ), ) } val textContent = Content(type = "text", text = prompt) val chatCompletionRequest = Request( model = actualModel, maxTokens = actualMaxTokens, messages = listOf(Message("user", imageContents + textContent)), ) val response = try { val httpResponse = httpClient.post(API_URL) { contentType(ContentType.Application.Json) headers["x-api-key"] = apiKey headers["anthropic-version"] = "2023-06-01" setBody(json.encodeToString(chatCompletionRequest)) } val body = httpResponse.bodyAsText() if (!httpResponse.status.isSuccess()) { logger.error("Failed to complete request to Anthropic: ${httpResponse.status}, $body") throw Exception("Failed to complete request to Anthropic: ${httpResponse.status}, $body") } if (httpResponse.status != HttpStatusCode.OK) { throw IllegalStateException("Call to Anthropic AI failed: $body") } json.decodeFromString(httpResponse.bodyAsText()) } catch (e: SerializationException) { logger.error("Failed to parse response from Anthropic", e) throw e } catch (e: Exception) { logger.error("Failed to complete request to Anthropic", e) throw e } return CompletionData( prompt = prompt, temperature = actualTemperature, maxTokens = actualMaxTokens, images = imagesBase64, model = actualModel, response = response.content.first().text!!, ) } override fun close() = httpClient.close() } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/anthropic/Common.kt ================================================ package maestro.ai.anthropic import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Message( val role: String, val content: List, ) @Serializable data class Content( val type: String, val text: String? = null, val source: ContentSource? = null, ) @Serializable data class ContentSource( val type: String, @SerialName("media_type") val mediaType: String, val data: String, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/anthropic/Request.kt ================================================ package maestro.ai.anthropic import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Request( val model: String, @SerialName("max_tokens") val maxTokens: Int, val messages: List, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/anthropic/Response.kt ================================================ import kotlinx.serialization.Serializable import maestro.ai.anthropic.Content @Serializable data class Response( val content: List, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/cloud/ApiClient.kt ================================================ package maestro.ai.cloud import io.ktor.client.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import maestro.ai.openai.OpenAI import org.slf4j.LoggerFactory private val logger = LoggerFactory.getLogger(OpenAI::class.java) @Serializable data class Defect( val category: String, val reasoning: String, ) @Serializable data class FindDefectsRequest( val assertion: String? = null, val screen: ByteArray, ) @Serializable data class FindDefectsResponse( val defects: List, ) @Serializable data class ExtractTextWithAiRequest( val query: String, val screen: ByteArray, ) @Serializable data class ExtractTextWithAiResponse( val text: String, ) class ApiClient { private val baseUrl by lazy { System.getenv("MAESTRO_CLOUD_API_URL") ?: "https://api.copilot.mobile.dev" } private val json = Json { ignoreUnknownKeys = true } val httpClient = HttpClient { install(ContentNegotiation) { Json { ignoreUnknownKeys = true } } install(HttpTimeout) { connectTimeoutMillis = 10000 socketTimeoutMillis = 60000 requestTimeoutMillis = 60000 } } suspend fun extractTextWithAi( apiKey: String, query: String, screen: ByteArray, ): ExtractTextWithAiResponse { val url = "$baseUrl/v2/extract-text" val response = try { val httpResponse = httpClient.post(url) { headers { append(HttpHeaders.Authorization, "Bearer $apiKey") append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) // Explicitly set JSON content type } setBody(json.encodeToString(ExtractTextWithAiRequest(query, screen))) } val body = httpResponse.bodyAsText() if (!httpResponse.status.isSuccess()) { logger.error("Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body") throw Exception("Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body") } json.decodeFromString(body) } catch (e: SerializationException) { logger.error("Failed to parse response from Maestro Cloud", e) throw e } catch (e: Exception) { logger.error("Failed to complete request to Maestro Cloud", e) throw e } return response } suspend fun findDefects( apiKey: String, screen: ByteArray, assertion: String? = null, ): FindDefectsResponse { val url = "$baseUrl/v2/find-defects" val response = try { val httpResponse = httpClient.post(url) { headers { append(HttpHeaders.Authorization, "Bearer $apiKey") append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) // Explicitly set JSON content type } setBody(json.encodeToString(FindDefectsRequest(assertion = assertion, screen = screen))) } val body = httpResponse.bodyAsText() if (!httpResponse.status.isSuccess()) { logger.error("Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body") throw Exception("Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body") } json.decodeFromString(body) } catch (e: SerializationException) { logger.error("Failed to parse response from Maestro Cloud", e) throw e } catch (e: Exception) { logger.error("Failed to complete request to Maestro Cloud", e) throw e } return response } } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/common/Image.kt ================================================ package maestro.ai.common import kotlinx.serialization.Serializable @Serializable data class Base64Image( val url: String, val detail: String, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/openai/Client.kt ================================================ package maestro.ai.openai import io.ktor.client.HttpClient import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.util.encodeBase64 import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import maestro.ai.AI import maestro.ai.CompletionData import maestro.ai.common.Base64Image import org.slf4j.LoggerFactory private const val API_URL = "https://api.openai.com/v1/chat/completions" private val logger = LoggerFactory.getLogger(OpenAI::class.java) class OpenAI( defaultModel: String = "gpt-4o", httpClient: HttpClient = defaultHttpClient, private val apiKey: String, private val defaultTemperature: Float = 0.2f, private val defaultMaxTokens: Int = 1024, private val defaultImageDetail: String = "high", ) : AI(defaultModel = defaultModel, httpClient = httpClient) { private val json = Json { ignoreUnknownKeys = true } override suspend fun chatCompletion( prompt: String, images: List, temperature: Float?, model: String?, maxTokens: Int?, imageDetail: String?, identifier: String?, jsonSchema: JsonObject?, ): CompletionData { val imagesBase64 = images.map { it.encodeBase64() } // Fallback to OpenAI defaults val actualTemperature = temperature ?: defaultTemperature val actualModel = model ?: defaultModel val actualMaxTokens = maxTokens ?: defaultMaxTokens val actualImageDetail = imageDetail ?: defaultImageDetail val imagesContent = imagesBase64.map { image -> ContentDetail( type = "image_url", imageUrl = Base64Image(url = "data:image/png;base64,$image", detail = actualImageDetail), ) } val textContent = ContentDetail(type = "text", text = prompt) val messages = listOf( MessageContent( role = "user", content = imagesContent + textContent, ) ) val chatCompletionRequest = ChatCompletionRequest( model = actualModel, temperature = actualTemperature, messages = messages, maxTokens = actualMaxTokens, seed = 1566, responseFormat = if (jsonSchema == null) null else ResponseFormat( type = "json_schema", jsonSchema = jsonSchema, ), ) val chatCompletionResponse = try { val httpResponse = httpClient.post(API_URL) { contentType(ContentType.Application.Json) headers["Authorization"] = "Bearer $apiKey" setBody(json.encodeToString(chatCompletionRequest)) } val body = httpResponse.bodyAsText() if (!httpResponse.status.isSuccess()) { logger.error("Failed to complete request to OpenAI: ${httpResponse.status}, $body") throw Exception("Failed to complete request to OpenAI: ${httpResponse.status}, $body") } json.decodeFromString(body) } catch (e: SerializationException) { logger.error("Failed to parse response from OpenAI", e) throw e } catch (e: Exception) { logger.error("Failed to complete request to OpenAI", e) throw e } return CompletionData( prompt = prompt, temperature = actualTemperature, maxTokens = actualMaxTokens, images = imagesBase64, model = actualModel, response = chatCompletionResponse.choices.first().message.content, ) } override fun close() = httpClient.close() } ================================================ FILE: maestro-ai/src/main/java/maestro/ai/openai/Request.kt ================================================ package maestro.ai.openai import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import maestro.ai.common.Base64Image @Serializable data class ChatCompletionRequest( val model: String, val messages: List, val temperature: Float, @SerialName("max_tokens") val maxTokens: Int, @SerialName("response_format") val responseFormat: ResponseFormat?, val seed: Int, ) @Serializable class ResponseFormat( val type: String, @SerialName("json_schema") val jsonSchema: JsonObject, ) @Serializable data class MessageContent( val role: String, val content: List, ) @Serializable data class ContentDetail( val type: String, val text: String? = null, @SerialName("image_url") val imageUrl: Base64Image? = null, ) ================================================ FILE: maestro-ai/src/main/java/maestro/ai/openai/Response.kt ================================================ package maestro.ai.openai import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ChatCompletionResponse( val id: String, val `object`: String, val created: Long, val model: String, @SerialName("system_fingerprint") val systemFingerprint: String? = null, val usage: Usage? = null, val choices: List, ) @Serializable data class Usage( @SerialName("prompt_tokens") val promptTokens: Int, @SerialName("completion_tokens") val completionTokens: Int? = null, @SerialName("total_tokens") val totalTokens: Int, ) @Serializable data class Choice( val message: Message, @SerialName("finish_details") val finishDetails: FinishDetails? = null, val index: Int, @SerialName("finish_reason") val finishReason: String? = null, ) @Serializable data class Message( val role: String, val content: String, ) @Serializable data class FinishDetails( val type: String, val stop: String? = null, ) ================================================ FILE: maestro-ai/src/main/resources/askForDefects_schema.json ================================================ { "name": "askForDefects", "description": "Returns a list of possible defects found in the mobile app's UI", "strict": true, "schema": { "type": "object", "required": ["defects"], "additionalProperties": false, "properties": { "defects": { "type": "array", "items": { "type": "object", "required": ["category", "reasoning"], "additionalProperties": false, "properties": { "category": { "type": "string", "enum": [ "layout", "localization" ] }, "reasoning": { "type": "string" } } } } } } } ================================================ FILE: maestro-ai/src/main/resources/extractText_schema.json ================================================ { "name": "extractText", "description": "Extracts text from an image based on a given query", "strict": true, "schema": { "type": "object", "required": [ "text" ], "additionalProperties": false, "properties": { "text": { "type": "string" } } } } ================================================ FILE: maestro-android/build.gradle.kts ================================================ import org.jetbrains.kotlin.config.JvmTarget plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.protobuf) } protobuf { protoc { artifact = "com.google.protobuf:protoc:${libs.versions.googleProtobuf.get()}" } plugins { create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") { option("lite") } } task.builtins { create("java") { option("lite") } create("kotlin") { option("lite") } } } } } kotlin.sourceSets.configureEach { // Prevent build warnings for grpc's generated opt-in code languageSettings.optIn("kotlin.RequiresOptIn") } android { namespace = "dev.mobile.maestro" compileSdk = 34 defaultConfig { applicationId = "dev.mobile.maestro" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { named("release") { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } named("debug") { signingConfig = signingConfigs.getByName("debug") } } signingConfigs { named("debug") { storeFile = file("../debug.keystore") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } packaging { resources { excludes += listOf("META-INF/INDEX.LIST", "META-INF/io.netty.versions.properties") } } } tasks.register("copyMaestroAndroid") { val maestroAndroidApkPath = "outputs/apk/debug/maestro-android-debug.apk" val maestroAndroidApkDest = "../../maestro-client/src/main/resources" val maestroAndroidApkDestPath = "../../maestro-client/src/main/resources/maestro-android-debug.apk" from(layout.buildDirectory.dir(maestroAndroidApkPath)) into(layout.buildDirectory.file(maestroAndroidApkDest)) doLast { if (!layout.buildDirectory.file(maestroAndroidApkDestPath).get().asFile.exists()) { throw GradleException("Error: Input source for copyMaestroAndroid doesn't exist") } File("./maestro-client/src/main/resources/maestro-android-debug.apk").renameTo(File("./maestro-client/src/main/resources/maestro-app.apk")) } } tasks.register("copyMaestroServer") { val maestroServerApkPath = "outputs/apk/androidTest/debug/maestro-android-debug-androidTest.apk" val maestroServerApkDest = "../../maestro-client/src/main/resources" val maestroServerApkDestPath = "../../maestro-client/src/main/resources/maestro-android-debug-androidTest.apk" from(layout.buildDirectory.dir(maestroServerApkPath)) into(layout.buildDirectory.file(maestroServerApkDest)) doLast { if (!layout.buildDirectory.file(maestroServerApkDestPath).get().asFile.exists()) { throw GradleException("Error: Input source for copyMaestroServer doesn't exist") } File("./maestro-client/src/main/resources/maestro-android-debug-androidTest.apk").renameTo(File("./maestro-client/src/main/resources/maestro-server.apk")) } } tasks.named("assemble") { // lint.enabled = false // lintVitalRelease.enabled = false finalizedBy("copyMaestroAndroid") } tasks.named("assembleAndroidTest") { // lint.enabled = false // lintVitalRelease.enabled = false finalizedBy("copyMaestroServer") } sourceSets { create("generated") { java { srcDirs( "build/generated/source/proto/main/grpc", "build/generated/source/proto/main/java", "build/generated/source/proto/main/kotlin", ) } } } dependencies { protobuf(project(":maestro-proto")) implementation(libs.grpc.kotlin.stub) implementation(libs.grpc.netty.shaded) implementation(libs.grpc.stub) implementation(libs.grpc.protobuf.lite) implementation(libs.grpc.okhttp) implementation(libs.google.protobuf.kotlin.lite) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.serial.gson) implementation(libs.commons.lang3) implementation(libs.hiddenapibypass) androidTestImplementation(libs.gmsLocation) implementation(libs.gmsLocation) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.kotlin.retry) } ================================================ FILE: maestro-android/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: maestro-android/src/androidTest/java/androidx/test/uiautomator/UiDeviceExt.kt ================================================ package androidx.test.uiautomator object UiDeviceExt { /** * Fix for a UiDevice.click() method that discards taps that happen outside of the screen bounds. * The issue with the original method is that it was computing screen bounds incorrectly. */ fun UiDevice.clickExt(x: Int, y: Int) { interactionController.clickNoSync( x, y ) } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/AccessibilityNodeInfoExt.kt ================================================ package dev.mobile.maestro import android.os.Build import android.view.accessibility.AccessibilityNodeInfo object AccessibilityNodeInfoExt { /** * Retrieves the hint text associated with this [android.view.accessibility.AccessibilityNodeInfo]. * * If the device API level is below 26 (Oreo) or the hint text is null, this function provides a fallback * by returning an empty CharSequence instead. * * @return [CharSequence] representing the hint text or its fallback. */ fun AccessibilityNodeInfo.getHintOrFallback(): CharSequence { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.hintText != null) { this.hintText } else { "" } } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/MaestroDriverService.kt ================================================ package dev.mobile.maestro import android.app.UiAutomation import android.content.Context import android.content.Context.LOCATION_SERVICE import android.location.Criteria import android.location.Location import android.location.LocationManager import android.os.Build import android.os.SystemClock import android.util.DisplayMetrics import android.util.Log import android.view.KeyEvent.KEYCODE_1 import android.view.KeyEvent.KEYCODE_4 import android.view.KeyEvent.KEYCODE_5 import android.view.KeyEvent.KEYCODE_6 import android.view.KeyEvent.KEYCODE_7 import android.view.KeyEvent.KEYCODE_APOSTROPHE import android.view.KeyEvent.KEYCODE_AT import java.util.concurrent.TimeUnit import android.view.KeyEvent.KEYCODE_BACKSLASH import android.view.KeyEvent.KEYCODE_COMMA import android.view.KeyEvent.KEYCODE_EQUALS import android.view.KeyEvent.KEYCODE_GRAVE import android.view.KeyEvent.KEYCODE_LEFT_BRACKET import android.view.KeyEvent.KEYCODE_MINUS import android.view.KeyEvent.KEYCODE_NUMPAD_ADD import android.view.KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN import android.view.KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN import android.view.KeyEvent.KEYCODE_PERIOD import android.view.KeyEvent.KEYCODE_POUND import android.view.KeyEvent.KEYCODE_RIGHT_BRACKET import android.view.KeyEvent.KEYCODE_SEMICOLON import android.view.KeyEvent.KEYCODE_SLASH import android.view.KeyEvent.KEYCODE_SPACE import android.view.KeyEvent.KEYCODE_STAR import android.view.KeyEvent.META_SHIFT_LEFT_ON import android.view.WindowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.Configurator import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDeviceExt.clickExt import com.google.android.gms.location.LocationServices import dev.mobile.maestro.location.FusedLocationProvider import dev.mobile.maestro.location.LocationManagerProvider import dev.mobile.maestro.location.MockLocationProvider import dev.mobile.maestro.location.PlayServices import dev.mobile.maestro.screenshot.ScreenshotException import dev.mobile.maestro.screenshot.ScreenshotService import io.grpc.Metadata import io.grpc.Status import io.grpc.StatusRuntimeException import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder import io.grpc.stub.StreamObserver import maestro_android.MaestroAndroid import maestro_android.MaestroDriverGrpc import maestro_android.addMediaResponse import maestro_android.checkWindowUpdatingResponse import maestro_android.deviceInfo import maestro_android.emptyResponse import maestro_android.eraseAllTextResponse import maestro_android.inputTextResponse import maestro_android.launchAppResponse import maestro_android.screenshotResponse import maestro_android.setLocationResponse import maestro_android.tapResponse import maestro_android.viewHierarchyResponse import org.junit.Test import org.junit.runner.RunWith import java.io.ByteArrayOutputStream import java.io.OutputStream import java.util.Timer import java.util.TimerTask import kotlin.system.measureTimeMillis /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class MaestroDriverService { @Test fun grpcServer() { Configurator.getInstance() .setActionAcknowledgmentTimeout(0L) .setWaitForIdleTimeout(0L) .setWaitForSelectorTimeout(0L) val instrumentation = InstrumentationRegistry.getInstrumentation() val uiDevice = UiDevice.getInstance(instrumentation) val uiAutomation = instrumentation.uiAutomation val port = InstrumentationRegistry.getArguments().getString("port", "7001").toInt() println("Server running on port [ $port ]") NettyServerBuilder.forPort(port) .addService(Service(uiDevice, uiAutomation)) .permitKeepAliveTime(30, TimeUnit.SECONDS) // If a client pings more than once every 30 seconds, terminate the connection .permitKeepAliveWithoutCalls(true) // Allow pings even when there are no active streams. .keepAliveTimeout(20, TimeUnit.SECONDS) // wait 20 seconds for client to ack the keep alive .maxConnectionIdle(30, TimeUnit.MINUTES) // If a client is idle for 30 minutes, send a GOAWAY frame. .build() .start() while (!Thread.interrupted()) { Thread.sleep(100) } } } class Service( private val uiDevice: UiDevice, private val uiAutomation: UiAutomation, ) : MaestroDriverGrpc.MaestroDriverImplBase() { private var locationTimerTask : TimerTask? = null private val locationTimer = Timer() private val screenshotService = ScreenshotService() private val mockLocationProviderList = mutableListOf() private val toastAccessibilityListener = ToastAccessibilityListener.start(uiAutomation) companion object { private const val TAG = "Maestro" private const val UPDATE_INTERVAL_IN_MILLIS = 2000L private val ERROR_TYPE_KEY: Metadata.Key = Metadata.Key.of("error-type", Metadata.ASCII_STRING_MARSHALLER) private val ERROR_MSG_KEY: Metadata.Key = Metadata.Key.of("error-message", Metadata.ASCII_STRING_MARSHALLER) private val ERROR_CAUSE_KEY: Metadata.Key = Metadata.Key.of("error-cause", Metadata.ASCII_STRING_MARSHALLER) } override fun launchApp( request: MaestroAndroid.LaunchAppRequest, responseObserver: StreamObserver ) { try { val context = InstrumentationRegistry.getInstrumentation().targetContext val intent = context.packageManager.getLaunchIntentForPackage(request.packageName) if (intent == null) { Log.e("Maestro", "No launcher intent found for package ${request.packageName}") responseObserver.onError(RuntimeException("No launcher intent found for package ${request.packageName}")) return } request.argumentsList .forEach { when (it.type) { String::class.java.name -> intent.putExtra(it.key, it.value) Boolean::class.java.name -> intent.putExtra(it.key, it.value.toBoolean()) Int::class.java.name -> intent.putExtra(it.key, it.value.toInt()) Double::class.java.name -> intent.putExtra(it.key, it.value.toDouble()) Long::class.java.name -> intent.putExtra(it.key, it.value.toLong()) else -> intent.putExtra(it.key, it.value) } } context.startActivity(intent) responseObserver.onNext(launchAppResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun deviceInfo( request: MaestroAndroid.DeviceInfoRequest, responseObserver: StreamObserver ) { try { val windowManager = InstrumentationRegistry.getInstrumentation() .context .getSystemService(Context.WINDOW_SERVICE) as WindowManager val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getRealMetrics(displayMetrics) responseObserver.onNext( deviceInfo { widthPixels = displayMetrics.widthPixels heightPixels = displayMetrics.heightPixels } ) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun viewHierarchy( request: MaestroAndroid.ViewHierarchyRequest, responseObserver: StreamObserver ) { try { refreshAccessibilityCache() val stream = ByteArrayOutputStream() val ms = measureTimeMillis { if (toastAccessibilityListener.getToastAccessibilityNode() != null && !toastAccessibilityListener.isTimedOut()) { Log.d("Maestro", "Requesting view hierarchy with toast") ViewHierarchy.dump( uiDevice, uiAutomation, stream, toastAccessibilityListener.getToastAccessibilityNode() ) } else { Log.d("Maestro", "Requesting view hierarchy") ViewHierarchy.dump( uiDevice, uiAutomation, stream ) } } Log.d("Maestro", "View hierarchy received in $ms ms") responseObserver.onNext( viewHierarchyResponse { hierarchy = stream.toString(Charsets.UTF_8.name()) } ) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } /** * Clears the in-process Accessibility cache, removing any stale references. Because the * AccessibilityInteractionClient singleton stores copies of AccessibilityNodeInfo instances, * calls to public APIs such as `recycle` do not guarantee cached references get updated. */ private fun refreshAccessibilityCache() { try { uiDevice.waitForIdle(500) uiAutomation.serviceInfo = null } catch (nullExp: NullPointerException) { /* no-op */ } } override fun tap( request: MaestroAndroid.TapRequest, responseObserver: StreamObserver ) { try { uiDevice.clickExt( request.x, request.y ) responseObserver.onNext(tapResponse {}) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun addMedia(responseObserver: StreamObserver): StreamObserver { return object : StreamObserver { var outputStream: OutputStream? = null override fun onNext(value: MaestroAndroid.AddMediaRequest) { if (outputStream == null) { outputStream = MediaStorage.getOutputStream( value.mediaName, value.mediaExt ) } value.payload.data.writeTo(outputStream) } override fun onError(t: Throwable) { responseObserver.onError(t.internalError()) } override fun onCompleted() { responseObserver.onNext(addMediaResponse { }) responseObserver.onCompleted() } } } override fun eraseAllText( request: MaestroAndroid.EraseAllTextRequest, responseObserver: StreamObserver ) { try { val charactersToErase = request.charactersToErase Log.d("Maestro", "Erasing text $charactersToErase") for (i in 1..charactersToErase) { uiDevice.pressDelete() } responseObserver.onNext(eraseAllTextResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun inputText( request: MaestroAndroid.InputTextRequest, responseObserver: StreamObserver ) { try { Log.d("Maestro", "Inputting text") request.text.forEach { setText(it.toString()) Thread.sleep(75) } responseObserver.onNext(inputTextResponse { }) responseObserver.onCompleted() } catch (e: Throwable) { responseObserver.onError(e.internalError()) } } override fun screenshot( request: MaestroAndroid.ScreenshotRequest, responseObserver: StreamObserver ) { try { val bitmap = screenshotService.takeScreenshotWithRetry { uiAutomation.takeScreenshot() } val bytes = screenshotService.encodePng(bitmap) responseObserver.onNext(screenshotResponse { this.bytes = bytes }) responseObserver.onCompleted() } catch (e: NullPointerException) { Log.e(TAG, "Screenshot failed with NullPointerException: ${e.message}", e) responseObserver.onError(e.internalError()) } catch (e: ScreenshotException) { Log.e(TAG, "Screenshot failed with ScreenshotException: ${e.message}", e) responseObserver.onError(e.internalError()) } catch (e: Exception) { Log.e(TAG, "Screenshot failed with: ${e.message}", e) responseObserver.onError(e.internalError()) } } override fun isWindowUpdating( request: MaestroAndroid.CheckWindowUpdatingRequest, responseObserver: StreamObserver ) { try { responseObserver.onNext(checkWindowUpdatingResponse { isWindowUpdating = uiDevice.waitForWindowUpdate(request.appId, 500) }) responseObserver.onCompleted() } catch (e: Throwable) { responseObserver.onError(e.internalError()) } } override fun disableLocationUpdates( request: MaestroAndroid.EmptyRequest, responseObserver: StreamObserver ) { try { Log.d(TAG, "[Start] Disabling location updates") locationTimerTask?.cancel() locationTimer.cancel() mockLocationProviderList.forEach { it.disable() } Log.d(TAG, "[Done] Disabling location updates") responseObserver.onNext(emptyResponse { }) responseObserver.onCompleted() } catch (exception: Exception) { responseObserver.onError(exception.internalError()) } } override fun enableMockLocationProviders( request: MaestroAndroid.EmptyRequest, responseObserver: StreamObserver ) { try { Log.d(TAG, "[Start] Enabling mock location providers") val context = InstrumentationRegistry.getInstrumentation().targetContext val locationManager = context.getSystemService(LOCATION_SERVICE) as LocationManager mockLocationProviderList.addAll( createMockProviders(context, locationManager) ) mockLocationProviderList.forEach { it.enable() } Log.d(TAG, "[Done] Enabling mock location providers") responseObserver.onNext(emptyResponse { }) responseObserver.onCompleted() } catch (exception: Exception) { Log.e(TAG, "Error while enabling mock location provider", exception) responseObserver.onError(exception.internalError()) } } private fun createMockProviders( context: Context, locationManager: LocationManager ): List { val playServices = PlayServices() val fusedLocationProvider: MockLocationProvider? = if (playServices.isAvailable(context)) { val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) FusedLocationProvider(fusedLocationProviderClient) } else { null } return (locationManager.allProviders.mapNotNull { if (it.equals(LocationManager.PASSIVE_PROVIDER)) { null } else { val mockProvider = createLocationManagerMockProvider(locationManager, it) mockProvider } } + fusedLocationProvider).mapNotNull { it } } private fun createLocationManagerMockProvider( locationManager: LocationManager, providerName: String? ): MockLocationProvider? { if (providerName == null) { return null } // API level check for existence of provider properties if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // API level 31 and above val providerProperties = locationManager.getProviderProperties(providerName) ?: return null return LocationManagerProvider( locationManager, providerName, providerProperties.hasNetworkRequirement(), providerProperties.hasSatelliteRequirement(), providerProperties.hasCellRequirement(), providerProperties.hasMonetaryCost(), providerProperties.hasAltitudeSupport(), providerProperties.hasSpeedSupport(), providerProperties.hasBearingSupport(), providerProperties.powerUsage, providerProperties.accuracy ) } val provider = locationManager.getProvider(providerName) ?: return null return LocationManagerProvider( locationManager, provider.name, provider.requiresNetwork(), provider.requiresSatellite(), provider.requiresCell(), provider.hasMonetaryCost(), provider.supportsAltitude(), provider.supportsSpeed(), provider.supportsBearing(), provider.powerRequirement, provider.accuracy ) } override fun setLocation( request: MaestroAndroid.SetLocationRequest, responseObserver: StreamObserver ) { try { if (locationTimerTask != null) { locationTimerTask?.cancel() } locationTimerTask = object : TimerTask() { override fun run() { mockLocationProviderList.forEach { val latitude = request.latitude val longitude = request.longitude Log.d(TAG, "Setting location latitude: $latitude and longitude: $longitude for ${it.getProviderName()}") val location = Location(it.getProviderName()).apply { setLatitude(latitude) setLongitude(longitude) accuracy = Criteria.ACCURACY_FINE.toFloat() altitude = 0.0 time = System.currentTimeMillis() elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() } it.setLocation(location) } } } locationTimer.schedule( locationTimerTask, 0, UPDATE_INTERVAL_IN_MILLIS ) responseObserver.onNext(setLocationResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } private fun setText(text: String) { for (element in text) { Log.d("Maestro", element.code.toString()) when (element.code) { in 48..57 -> { /** 0~9 **/ uiDevice.pressKeyCode(element.code - 41) } in 65..90 -> { /** A~Z **/ uiDevice.pressKeyCode(element.code - 36, 1) } in 97..122 -> { /** a~z **/ uiDevice.pressKeyCode(element.code - 68) } ';'.code -> uiDevice.pressKeyCode(KEYCODE_SEMICOLON) '='.code -> uiDevice.pressKeyCode(KEYCODE_EQUALS) ','.code -> uiDevice.pressKeyCode(KEYCODE_COMMA) '-'.code -> uiDevice.pressKeyCode(KEYCODE_MINUS) '.'.code -> uiDevice.pressKeyCode(KEYCODE_PERIOD) '/'.code -> uiDevice.pressKeyCode(KEYCODE_SLASH) '`'.code -> uiDevice.pressKeyCode(KEYCODE_GRAVE) '\''.code -> uiDevice.pressKeyCode(KEYCODE_APOSTROPHE) '['.code -> uiDevice.pressKeyCode(KEYCODE_LEFT_BRACKET) ']'.code -> uiDevice.pressKeyCode(KEYCODE_RIGHT_BRACKET) '\\'.code -> uiDevice.pressKeyCode(KEYCODE_BACKSLASH) ' '.code -> uiDevice.pressKeyCode(KEYCODE_SPACE) '@'.code -> uiDevice.pressKeyCode(KEYCODE_AT) '#'.code -> uiDevice.pressKeyCode(KEYCODE_POUND) '*'.code -> uiDevice.pressKeyCode(KEYCODE_STAR) '('.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_LEFT_PAREN) ')'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_RIGHT_PAREN) '+'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_ADD) '!'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_1) '$'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_4) '%'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_5) '^'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_6) '&'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_7) '"'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_APOSTROPHE) '{'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_LEFT_BRACKET) '}'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_RIGHT_BRACKET) ':'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SEMICOLON) '|'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_BACKSLASH) '<'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_COMMA) '>'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_PERIOD) '?'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SLASH) '~'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_GRAVE) '_'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_MINUS) } } } private fun keyPressShiftedToEvents(uiDevice: UiDevice, keyCode: Int) { uiDevice.pressKeyCode(keyCode, META_SHIFT_LEFT_ON) } internal fun Throwable.internalError(): StatusRuntimeException { val trailers = Metadata().apply { put(ERROR_TYPE_KEY, this@internalError::class.java.name) this@internalError.message?.let { put(ERROR_MSG_KEY, it) } this@internalError.cause?.let { put(ERROR_CAUSE_KEY, it.toString()) } } return Status.INTERNAL.withDescription(message).asRuntimeException(trailers) } enum class FileType(val ext: String, val mimeType: String) { JPG("jpg", "image/jpg"), JPEG("jpeg", "image/jpeg"), PNG("png", "image/png"), GIF("gif", "image/gif"), MP4("mp4", "video/mp4"), } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/Media.kt ================================================ package dev.mobile.maestro import android.content.ContentValues import android.provider.MediaStore import androidx.test.platform.app.InstrumentationRegistry import java.io.OutputStream object MediaStorage { fun getOutputStream(mediaName: String, mediaExt: String): OutputStream? { val uri = when (mediaExt) { Service.FileType.JPG.ext, Service.FileType.PNG.ext, Service.FileType.GIF.ext, Service.FileType.JPEG.ext -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI Service.FileType.MP4.ext -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI else -> throw IllegalStateException("mime .$mediaExt not yet supported") } val ext = Service.FileType.values().first { it.ext == mediaExt } val contentValues = ContentValues() contentValues.apply { put(MediaStore.MediaColumns.DISPLAY_NAME, mediaName) put(MediaStore.MediaColumns.MIME_TYPE, ext.mimeType) } val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver val outputStream = contentResolver.insert(uri, contentValues)?.let { contentResolver.openOutputStream(it) } return outputStream } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/ToastAccessibilityListener.kt ================================================ package dev.mobile.maestro import android.app.UiAutomation import android.os.Build import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.widget.Toast import kotlin.reflect.jvm.jvmName object ToastAccessibilityListener : UiAutomation.OnAccessibilityEventListener { private var toastNode: AccessibilityNodeInfo? = null private var isListening = false private var recentToastTimeMillis: Long = 0 private const val TOAST_LENGTH_LONG_DURATION = 3500 override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent) { if ( accessibilityEvent.eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED && accessibilityEvent.className.toString().contains(Toast::class.jvmName) ) { recentToastTimeMillis = System.currentTimeMillis() // Constructor for AccessibilityNodeInfo is only available on Android API 30+ val nodeInfo = if (Build.VERSION.SDK_INT < 30) { AccessibilityNodeInfo.obtain() } else { AccessibilityNodeInfo() } toastNode = nodeInfo.apply { text = accessibilityEvent.text.first().toString() className = Toast::class.jvmName isVisibleToUser = true viewIdResourceName = "" packageName = "" isCheckable = false isChecked = accessibilityEvent.isChecked isClickable = false isEnabled = accessibilityEvent.isEnabled Log.d("Maestro", "Toast received with $text") } } } fun getToastAccessibilityNode() = toastNode fun isTimedOut(): Boolean { return System.currentTimeMillis() - recentToastTimeMillis > TOAST_LENGTH_LONG_DURATION } fun start(uiAutomation: UiAutomation): ToastAccessibilityListener { if (isListening) return this uiAutomation.setOnAccessibilityEventListener(this) isListening = true Log.d("Maestro", "Started listening to accessibility events") return this } fun stop() { isListening = false Log.d("Maestro", "Stopped listening to accessibility events") } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/ViewHierarchy.kt ================================================ package dev.mobile.maestro import android.app.UiAutomation import android.content.Context import android.graphics.Rect import android.os.Build import android.util.DisplayMetrics import android.util.Log import android.util.Xml import android.view.WindowManager import android.view.accessibility.AccessibilityNodeInfo import android.widget.GridLayout import android.widget.GridView import android.widget.ListView import android.widget.TableLayout import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import dev.mobile.maestro.AccessibilityNodeInfoExt.getHintOrFallback import org.xmlpull.v1.XmlSerializer import java.io.IOException import java.io.OutputStream // Logic largely copied from AccessibilityNodeInfoDumper with some modifications object ViewHierarchy { private const val LOGTAG = "Maestro" fun dump( device: UiDevice, uiAutomation: UiAutomation, out: OutputStream, toastNode: AccessibilityNodeInfo? = null ) { val windowManager = InstrumentationRegistry.getInstrumentation() .context .getSystemService(Context.WINDOW_SERVICE) as WindowManager val displayRect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { windowManager.currentWindowMetrics.bounds } else { val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getRealMetrics(displayMetrics) Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels) } val serializer = Xml.newSerializer() serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true) serializer.setOutput(out, "UTF-8") serializer.startDocument("UTF-8", true) serializer.startTag("", "hierarchy") serializer.attribute("", "rotation", Integer.toString(device.displayRotation)) val roots = try { device.javaClass .getDeclaredMethod("getWindowRoots") .apply { isAccessible = true } .let { @Suppress("UNCHECKED_CAST") it.invoke(device) as Array } .toList() } catch (e: Exception) { // Falling back to a public method if reflection fails Log.e(LOGTAG, "Unable to call getWindowRoots", e) listOf(uiAutomation.rootInActiveWindow) } roots.forEach { dumpNodeRec( it, serializer, 0, displayRect ) } addToastNode(toastNode, serializer, displayRect) serializer.endTag("", "hierarchy") serializer.endDocument() } private fun addToastNode( toastNode: AccessibilityNodeInfo?, serializer: XmlSerializer, displayRect: Rect ) { if (toastNode != null) { serializer.apply { startTag("", "node") attribute("", "index", "0") attribute("", "class", toastNode.className.toString()) attribute("", "text", toastNode.text.toString()) attribute("", "visible-to-user", toastNode.isVisibleToUser.toString()) attribute("", "checkable", toastNode.isCheckable.toString()) attribute("", "clickable", toastNode.isClickable.toString()) attribute("", "bounds", getVisibleBoundsInScreen(toastNode, displayRect)?.toShortString()) endTag("", "node") } } } private val NAF_EXCLUDED_CLASSES = arrayOf( GridView::class.java.name, GridLayout::class.java.name, ListView::class.java.name, TableLayout::class.java.name ) @Suppress("LongParameterList") @Throws(IOException::class) private fun dumpNodeRec( node: AccessibilityNodeInfo, serializer: XmlSerializer, index: Int, displayRect: Rect, insideWebView: Boolean = false, ) { serializer.startTag("", "node") if (!nafExcludedClass(node) && !nafCheck(node)) { serializer.attribute("", "NAF", java.lang.Boolean.toString(true)) } serializer.attribute("", "index", Integer.toString(index)) serializer.attribute("", "hintText", safeCharSeqToString(node.getHintOrFallback())) serializer.attribute("", "text", safeCharSeqToString(node.text)) serializer.attribute("", "resource-id", safeCharSeqToString(node.viewIdResourceName)) serializer.attribute("", "class", safeCharSeqToString(node.className)) serializer.attribute("", "package", safeCharSeqToString(node.packageName)) serializer.attribute("", "content-desc", safeCharSeqToString(node.contentDescription)) serializer.attribute("", "checkable", java.lang.Boolean.toString(node.isCheckable)) serializer.attribute("", "checked", java.lang.Boolean.toString(node.isChecked)) serializer.attribute("", "clickable", java.lang.Boolean.toString(node.isClickable)) serializer.attribute("", "enabled", java.lang.Boolean.toString(node.isEnabled)) serializer.attribute("", "focusable", java.lang.Boolean.toString(node.isFocusable)) serializer.attribute("", "focused", java.lang.Boolean.toString(node.isFocused)) serializer.attribute("", "scrollable", java.lang.Boolean.toString(node.isScrollable)) serializer.attribute("", "long-clickable", java.lang.Boolean.toString(node.isLongClickable)) serializer.attribute("", "password", java.lang.Boolean.toString(node.isPassword)) serializer.attribute("", "selected", java.lang.Boolean.toString(node.isSelected)) serializer.attribute("", "visible-to-user", java.lang.Boolean.toString(node.isVisibleToUser)) serializer.attribute("", "important-for-accessibility", java.lang.Boolean.toString(node.isImportantForAccessibility)) serializer.attribute("", "error", safeCharSeqToString(node.error)) serializer.attribute( "", "bounds", getVisibleBoundsInScreen(node, displayRect)?.toShortString() ) val count = node.childCount for (i in 0 until count) { val child = node.getChild(i) if (child != null) { // This condition is different from the original. // Original implementation has a bug where contents of a WebView sometimes reported as invisible. // This is a workaround for that bug. if (child.isVisibleToUser || insideWebView) { dumpNodeRec( child, serializer, i, displayRect, insideWebView || child.className == "android.webkit.WebView" ) child.recycle() } else { Log.i(LOGTAG, "Skipping invisible child: $child") } } else { Log.i(LOGTAG, "Null child $i/$count, parent: $node") } } serializer.endTag("", "node") } /** * The list of classes to exclude my not be complete. We're attempting to * only reduce noise from standard layout classes that may be falsely * configured to accept clicks and are also enabled. * * @param node * @return true if node is excluded. */ private fun nafExcludedClass(node: AccessibilityNodeInfo): Boolean { val className = safeCharSeqToString(node.className) for (excludedClassName in NAF_EXCLUDED_CLASSES) { if (className.endsWith(excludedClassName)) return true } return false } /** * We're looking for UI controls that are enabled, clickable but have no * text nor content-description. Such controls configuration indicate an * interactive control is present in the UI and is most likely not * accessibility friendly. We refer to such controls here as NAF controls * (Not Accessibility Friendly) * * @param node * @return false if a node fails the check, true if all is OK */ private fun nafCheck(node: AccessibilityNodeInfo): Boolean { val isNaf = (node.isClickable && node.isEnabled && safeCharSeqToString(node.contentDescription).isEmpty() && safeCharSeqToString(node.text).isEmpty()) return if (!isNaf) true else childNafCheck(node) // check children since sometimes the containing element is clickable // and NAF but a child's text or description is available. Will assume // such layout as fine. } /** * This should be used when it's already determined that the node is NAF and * a further check of its children is in order. A node maybe a container * such as LinearLayout and may be set to be clickable but have no text or * content description but it is counting on one of its children to fulfill * the requirement for being accessibility friendly by having one or more of * its children fill the text or content-description. Such a combination is * considered by this dumper as acceptable for accessibility. * * @param node * @return false if node fails the check. */ @Suppress("ReturnCount") private fun childNafCheck(node: AccessibilityNodeInfo): Boolean { val childCount = node.childCount for (x in 0 until childCount) { val childNode = node.getChild(x) if (childNode == null) continue; if (!safeCharSeqToString(childNode.contentDescription).isEmpty() || !safeCharSeqToString(childNode.text).isEmpty() ) return true if (childNafCheck(childNode)) return true } return false } private fun safeCharSeqToString(cs: CharSequence?): String { return cs?.let { stripInvalidXMLChars(it) } ?: "" } @Suppress("ComplexCondition") private fun stripInvalidXMLChars(cs: CharSequence): String { val ret = StringBuffer() var ch: Char /* http://www.w3.org/TR/xml11/#charsets [#x1-#x8], [#xB-#xC], [#xE-#x1F], [#x7F-#x84], [#x86-#x9F], [#xFDD0-#xFDDF], [#x1FFFE-#x1FFFF], [#x2FFFE-#x2FFFF], [#x3FFFE-#x3FFFF], [#x4FFFE-#x4FFFF], [#x5FFFE-#x5FFFF], [#x6FFFE-#x6FFFF], [#x7FFFE-#x7FFFF], [#x8FFFE-#x8FFFF], [#x9FFFE-#x9FFFF], [#xAFFFE-#xAFFFF], [#xBFFFE-#xBFFFF], [#xCFFFE-#xCFFFF], [#xDFFFE-#xDFFFF], [#xEFFFE-#xEFFFF], [#xFFFFE-#xFFFFF], [#x10FFFE-#x10FFFF]. */for (i in 0 until cs.length) { ch = cs[i] if (ch.code >= 0x1 && ch.code <= 0x8 || ch.code >= 0xB && ch.code <= 0xC || ch.code >= 0xE && ch.code <= 0x1F || ch.code >= 0x7F && ch.code <= 0x84 || ch.code >= 0x86 && ch.code <= 0x9f || ch.code >= 0xFDD0 && ch.code <= 0xFDDF || ch.code >= 0x1FFFE && ch.code <= 0x1FFFF || ch.code >= 0x2FFFE && ch.code <= 0x2FFFF || ch.code >= 0x3FFFE && ch.code <= 0x3FFFF || ch.code >= 0x4FFFE && ch.code <= 0x4FFFF || ch.code >= 0x5FFFE && ch.code <= 0x5FFFF || ch.code >= 0x6FFFE && ch.code <= 0x6FFFF || ch.code >= 0x7FFFE && ch.code <= 0x7FFFF || ch.code >= 0x8FFFE && ch.code <= 0x8FFFF || ch.code >= 0x9FFFE && ch.code <= 0x9FFFF || ch.code >= 0xAFFFE && ch.code <= 0xAFFFF || ch.code >= 0xBFFFE && ch.code <= 0xBFFFF || ch.code >= 0xCFFFE && ch.code <= 0xCFFFF || ch.code >= 0xDFFFE && ch.code <= 0xDFFFF || ch.code >= 0xEFFFE && ch.code <= 0xEFFFF || ch.code >= 0xFFFFE && ch.code <= 0xFFFFF || ch.code >= 0x10FFFE && ch.code <= 0x10FFFF ) ret.append(".") else ret.append(ch) } return ret.toString() } // This method is copied from AccessibilityNodeInfoHelper as-is private fun getVisibleBoundsInScreen(node: AccessibilityNodeInfo?, displayRect: Rect): Rect? { if (node == null) { return null } // targeted node's bounds val nodeRect = Rect() node.getBoundsInScreen(nodeRect) return if (nodeRect.intersect(displayRect)) { nodeRect } else { Rect() } } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/location/FusedLocationProvider.kt ================================================ package dev.mobile.maestro.location import android.location.Location import com.google.android.gms.location.FusedLocationProviderClient class FusedLocationProvider( private val fusedLocationProviderClient: FusedLocationProviderClient ): MockLocationProvider { companion object { private const val PROVIDER_NAME = "fused" private val TAG = FusedLocationProvider::class.java.name } override fun setLocation(location: Location) { fusedLocationProviderClient.setMockLocation(location) } override fun enable() { fusedLocationProviderClient.setMockMode(true) } override fun disable() { fusedLocationProviderClient.setMockMode(false) } override fun getProviderName(): String { return PROVIDER_NAME } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/location/LocationManagerProvider.kt ================================================ package dev.mobile.maestro.location import android.location.Location import android.location.LocationManager class LocationManagerProvider( private val locationManager: LocationManager, private val name: String, private val requiresNetwork: Boolean, private val requiresCell: Boolean, private val requiresSatellite: Boolean, private val hasMonetaryCost: Boolean, private val supportsAltitude: Boolean, private val supportsSpeed: Boolean, private val supportsBearing: Boolean, private val powerRequirement: Int, private val accuracy: Int ) : MockLocationProvider { override fun setLocation(location: Location) { locationManager.setTestProviderLocation(name, location) } override fun enable() { locationManager.addTestProvider( name, requiresNetwork, requiresSatellite, requiresCell, hasMonetaryCost, supportsAltitude, supportsSpeed, supportsBearing, powerRequirement, accuracy ) locationManager.setTestProviderEnabled(name, true) } override fun disable() { locationManager.setTestProviderEnabled(name, false) locationManager.removeTestProvider(name) } override fun getProviderName(): String { return name } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/location/MockLocationProvider.kt ================================================ package dev.mobile.maestro.location import android.location.Location interface MockLocationProvider { fun setLocation(location: Location) fun enable() fun disable() fun getProviderName(): String } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/location/PlayServices.kt ================================================ package dev.mobile.maestro.location import android.content.Context import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability class PlayServices { fun isAvailable(context: Context): Boolean { val apiAvailability = GoogleApiAvailability.getInstance() val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) return resultCode == ConnectionResult.SUCCESS } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/screenshot/ScreenshotService.kt ================================================ package dev.mobile.maestro.screenshot import android.graphics.Bitmap import com.github.michaelbull.retry.policy.binaryExponentialBackoff import com.github.michaelbull.retry.policy.continueIf import com.github.michaelbull.retry.policy.plus import com.github.michaelbull.retry.policy.stopAtAttempts import com.github.michaelbull.retry.retry import com.google.protobuf.ByteString import java.io.ByteArrayOutputStream import kotlinx.coroutines.runBlocking /** * Thrown when screenshot encoding fails (e.g. [Bitmap.compress] throws or returns false). */ class ScreenshotException( message: String, cause: Throwable? = null ) : Exception(message, cause) /** * Encodes screenshots to PNG (or other formats) with validation and size limits. */ class ScreenshotService() { /** * Takes a screenshot with retry logic. Retries up to 3 times with exponential backoff * when the screenshot returns null (window not ready / SurfaceControl invalid). */ fun takeScreenshotWithRetry(screenshotProvider: () -> Bitmap?): Bitmap { return runBlocking { retry( stopAtAttempts(3) + continueIf { it.failure is NullPointerException } + binaryExponentialBackoff(min = 500L, max = 5000L) ) { screenshotProvider() ?: throw NullPointerException("Screenshot returned null — window may not be ready") } } } /** * Encodes a screenshot bitmap to PNG bytes. * * @throws ScreenshotException if compression fails or output is invalid/too large. */ fun encodePng(bitmap: Bitmap, quality: Int = 100): ByteString = encode(bitmap, Bitmap.CompressFormat.PNG, quality) /** * Generic encoder for any [Bitmap.CompressFormat]. * * @throws ScreenshotException if compression fails or output is invalid/too large. */ fun encode( bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int = 100, ): ByteString { validateQuality(format, quality) val outputStream = ByteArrayOutputStream() val ok = try { bitmap.compress(format, quality, outputStream) } catch (t: Throwable) { throw ScreenshotException( message = "Bitmap compression failed: format=${format.name}, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}", cause = t ) } if (!ok) { throw ScreenshotException( message = "Bitmap.compress returned false: format=${format.name}, quality=$quality, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}" ) } val bytes = outputStream.toByteArray() if (bytes.isEmpty()) { throw ScreenshotException( message = "Bitmap compressed but produced empty output: format=${format.name}, quality=$quality, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}" ) } return ByteString.copyFrom(bytes) } private fun validateQuality(format: Bitmap.CompressFormat, quality: Int) { if (quality !in 0..100) { throw IllegalArgumentException("quality must be in 0..100, got $quality") } } } ================================================ FILE: maestro-android/src/androidTest/java/dev/mobile/maestro/screenshot/ScreenshotServiceTest.kt ================================================ package dev.mobile.maestro.screenshot import android.graphics.Bitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @Ignore("Disabled since this structure of unit test is weird - We should have libs which should be having their own tests") @RunWith(AndroidJUnit4::class) class ScreenshotServiceTest { private val screenshotService = ScreenshotService() @Test fun encodePng_withValidBitmap_returnsBytes() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) try { val result = screenshotService.encodePng(bitmap) assertNotNull(result) assertTrue("Encoded bytes should not be empty", result.size() > 0) } finally { bitmap.recycle() } } @Test fun encode_withInvalidQualityTooHigh_throwsIllegalArgumentException() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) try { screenshotService.encode(bitmap, Bitmap.CompressFormat.PNG, quality = 101) fail("Expected IllegalArgumentException to be thrown") } catch (e: IllegalArgumentException) { assertTrue( "Message should mention quality", e.message?.contains("quality") == true ) } finally { bitmap.recycle() } } @Test fun encode_withInvalidQualityNegative_throwsIllegalArgumentException() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) try { screenshotService.encode(bitmap, Bitmap.CompressFormat.PNG, quality = -1) fail("Expected IllegalArgumentException to be thrown") } catch (e: IllegalArgumentException) { assertTrue( "Message should mention quality", e.message?.contains("quality") == true ) } finally { bitmap.recycle() } } @Test fun encode_withJpegFormat_returnsBytes() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) try { val result = screenshotService.encode(bitmap, Bitmap.CompressFormat.JPEG, quality = 80) assertNotNull(result) assertTrue("Encoded bytes should not be empty", result.size() > 0) } finally { bitmap.recycle() } } @Test fun encode_withWebpFormat_returnsBytes() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) try { @Suppress("DEPRECATION") val result = screenshotService.encode(bitmap, Bitmap.CompressFormat.WEBP, quality = 80) assertNotNull(result) assertTrue("Encoded bytes should not be empty", result.size() > 0) } finally { bitmap.recycle() } } @Test fun takeScreenshotWithRetry_returnsOnFirstSuccess() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) var callCount = 0 try { val result = screenshotService.takeScreenshotWithRetry { callCount++ bitmap } assertSame(bitmap, result) assertEquals(1, callCount) } finally { bitmap.recycle() } } @Test fun takeScreenshotWithRetry_retriesOnNull() { val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) var callCount = 0 try { val result = screenshotService.takeScreenshotWithRetry { callCount++ if (callCount < 3) null else bitmap } assertSame(bitmap, result) assertEquals(3, callCount) } finally { bitmap.recycle() } } @Test fun takeScreenshotWithRetry_throwsAfterAllRetriesExhausted() { var callCount = 0 try { screenshotService.takeScreenshotWithRetry { callCount++ null } fail("Expected NullPointerException to be thrown") } catch (e: NullPointerException) { assertEquals(3, callCount) } } } ================================================ FILE: maestro-android/src/main/AndroidManifest.xml ================================================ ================================================ FILE: maestro-android/src/main/java/dev/mobile/maestro/handlers/AbstractSettingHandler.kt ================================================ package dev.mobile.maestro.handlers import android.content.Context import android.content.pm.PackageManager import android.util.Log abstract class AbstractSettingHandler(private val context: Context, private val permissions: List) { protected fun hasPermissions(): Boolean { for (p in permissions) { if (context.checkCallingOrSelfPermission(p) != PackageManager.PERMISSION_GRANTED) { val logMessage = String.format( "The permission %s is not set. Cannot change state of %s.", p, settingDescription ) Log.e(TAG, logMessage) return false } } return true } abstract fun setState(state: Boolean): Boolean abstract val settingDescription: String companion object { private const val TAG = "Maestro" } } ================================================ FILE: maestro-android/src/main/java/dev/mobile/maestro/handlers/LocaleSettingHandler.kt ================================================ package dev.mobile.maestro.handlers import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.os.Build import org.lsposed.hiddenapibypass.HiddenApiBypass import java.lang.reflect.InvocationTargetException import java.util.* class LocaleSettingHandler(context: Context) : AbstractSettingHandler(context, listOf(CHANGE_CONFIGURATION)) { fun setLocale(locale: Locale) { if (hasPermissions()) { setLocaleWith(locale) } } @SuppressLint("PrivateApi") @Throws( ClassNotFoundException::class, NoSuchMethodException::class, InvocationTargetException::class, IllegalAccessException::class, NoSuchFieldException::class ) private fun setLocaleWith(locale: Locale) { var activityManagerNativeClass = Class.forName("android.app.ActivityManagerNative") val methodGetDefault = activityManagerNativeClass.getMethod("getDefault") methodGetDefault.isAccessible = true val amn = methodGetDefault.invoke(activityManagerNativeClass) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // getConfiguration moved from ActivityManagerNative to ActivityManagerProxy activityManagerNativeClass = Class.forName(amn.javaClass.name) } val methodGetConfiguration = activityManagerNativeClass.getMethod("getConfiguration") methodGetConfiguration.isAccessible = true val config = methodGetConfiguration.invoke(amn) as Configuration val configClass: Class<*> = config.javaClass val f = configClass.getField("userSetLocale") f.setBoolean(config, true) config.locale = locale config.setLayoutDirection(locale) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { HiddenApiBypass.invoke( activityManagerNativeClass, amn, "updateConfiguration", config ) } else { val methodUpdateConfiguration = activityManagerNativeClass.getMethod( "updateConfiguration", Configuration::class.java ) methodUpdateConfiguration.isAccessible = true methodUpdateConfiguration.invoke(amn, config) } } override fun setState(state: Boolean): Boolean { return false } override val settingDescription: String = "locale" companion object { private const val CHANGE_CONFIGURATION = "android.permission.CHANGE_CONFIGURATION" } } ================================================ FILE: maestro-android/src/main/java/dev/mobile/maestro/receivers/HasAction.kt ================================================ package dev.mobile.maestro.receivers interface HasAction { fun action(): String } ================================================ FILE: maestro-android/src/main/java/dev/mobile/maestro/receivers/LocaleSettingReceiver.kt ================================================ package dev.mobile.maestro.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log import dev.mobile.maestro.handlers.LocaleSettingHandler import org.apache.commons.lang3.LocaleUtils import java.util.* import java.lang.Exception class LocaleSettingReceiver : BroadcastReceiver(), HasAction { override fun onReceive(context: Context, intent: Intent) { var language = intent.getStringExtra(LANG) var country = intent.getStringExtra(COUNTRY) if (language == null || country == null) { Log.w( TAG, "It is required to provide both language and country, for example: " + "am broadcast -a dev.mobile.maestro --es lang ja --es country JP" ) Log.i(TAG, "Set en-US by default.") language = "en" country = "US" } var locale = Locale(language, country) Log.i(TAG, "Obtained locale: $locale") try { Log.i(TAG, "getting string extra for device locale") val script = intent.getStringExtra(SCRIPT) if (script != null) { Log.i(TAG, "setting script with device locale") Locale.Builder().setLocale(locale).setScript(script).build().also { locale = it } Log.i(TAG, "script set for device locale") } if (!LocaleUtils.isAvailableLocale(locale)) { val approximateMatchesLc = matchLocales(language, country) if (approximateMatchesLc.isNotEmpty() && script.isNullOrBlank()) { Log.i( TAG, "The locale $locale is not known. Selecting the closest known one ${approximateMatchesLc[0]} instead" ) locale = approximateMatchesLc[0] } else { val approximateMatchesL = matchLocales(language) if (approximateMatchesL.isEmpty()) { Log.e( TAG, "The locale $locale is not known. Only the following locales are available: ${LocaleUtils.availableLocaleList()}" ) } else { Log.e( TAG, "The locale $locale is not known. " + "The following locales are available for the $language language: $approximateMatchesL" + "The following locales are available altogether: ${LocaleUtils.availableLocaleList()}" ) } resultCode = RESULT_LOCALE_NOT_VALID resultData = "Failed to set locale $locale, the locale is not valid" return } } } catch (e: Exception) { Log.e(TAG, "Failed to validate device locale", e) resultCode = RESULT_LOCALE_VALIDATION_FAILED resultData = "Failed to set locale $locale: ${e.message}" } try { LocaleSettingHandler(context).setLocale(locale) Log.i(TAG, "Set locale: $locale") resultCode = RESULT_SUCCESS resultData = locale.toString() } catch (e: Exception) { Log.e(TAG, "Failed to set locale", e) resultCode = RESULT_UPDATE_CONFIGURATION_FAILED resultData = "Failed to set locale $locale, exception during updating configuration occurred: $e" } } private fun matchLocales(language: String): List { val matches = ArrayList() for (locale in LocaleUtils.availableLocaleList()) { if (locale.language == language) { matches.add(locale) } } return matches } private fun matchLocales(language: String, country: String): List { val matches = ArrayList() for (locale in LocaleUtils.availableLocaleList()) { if (locale.language == language && locale.country == country ) { matches.add(locale) } } return matches } override fun action(): String { return ACTION } companion object { private const val LANG = "lang" private const val COUNTRY = "country" private const val SCRIPT = "script" private const val ACTION = "dev.mobile.maestro.locale" private const val TAG = "Maestro" private const val RESULT_SUCCESS = 0 private const val RESULT_LOCALE_NOT_VALID = 1 private const val RESULT_UPDATE_CONFIGURATION_FAILED = 2 private const val RESULT_LOCALE_VALIDATION_FAILED = 3 } } ================================================ FILE: maestro-android/src/main/res/values/stub.xml ================================================ Maestro Driver ================================================ FILE: maestro-cli/build.gradle.kts ================================================ import org.jreleaser.model.Active.ALWAYS import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import org.jreleaser.model.Stereotype import java.util.Properties @Suppress("DSL_SCOPE_VIOLATION") plugins { application alias(libs.plugins.kotlin.jvm) alias(libs.plugins.jreleaser) alias(libs.plugins.shadow) alias(libs.plugins.mavenPublish) alias(libs.plugins.kotlin.serialization) } group = "dev.mobile" val CLI_VERSION: String by project application { applicationName = "maestro" mainClass.set("maestro.cli.AppKt") } tasks.named("jar") { manifest { attributes["Main-Class"] = "maestro.cli.AppKt" } // Include the driver source directly from("../maestro-ios-xctest-runner") { into("driver/ios") include( "maestro-driver-ios/**", "maestro-driver-iosUITests/**", "maestro-driver-ios.xcodeproj/**", ) } } tasks.named("run") { standardInput = System.`in` workingDir = rootDir } /** The source that was used to create JvmVersion is here. It was compiled with JDK 1.1 * on a Windows 32 bit machine using https://www.oracle.com/java/technologies/java-archive-downloads-javase11-downloads.html * with a META-INF/MANIFEST.MF of Main-Class: JvmVersion for the jvm-version.jar * * import java.util.StringTokenizer; * * class JvmVersion { * public static void main(String[] args) { * try { * String javaVersion = System.getProperty("java.version"); * StringTokenizer tokenizer = new StringTokenizer(javaVersion, "."); * String[] split = new String[tokenizer.countTokens()]; * int count = 0; * while (tokenizer.hasMoreTokens()) { * split[count] = tokenizer.nextToken(); * count++; * } * if (javaVersion.startsWith("1.")) { * String version = split[1]; * if (Integer.parseInt(version) >= 1 && Integer.parseInt(version) <= 8) { * System.out.println(version); * System.exit(0); * } else { * String base = "Expected a JVM version of 1.0 through to 1.8 for legacy JVM versioning. Instead got "; * String output = base.concat(version); * System.out.println(output); * System.exit(1); * } * } else { * String version = split[0]; * if (Integer.parseInt(version) >= 9) { * System.out.println(version); * System.exit(0); * } else { * String base = "Expected a JVM version of 9 or greater for new JVM versioning. Instead got "; * String output = base.concat(version); * System.out.println(output); * System.exit(1); * } * } * } catch (Exception e) { * System.err.println(e.getMessage()); * System.exit(1); * } * } * } * */ fun windowsMinimumJavaText(minimumJavaVersion: String): String = """ set JAVA_VERSION=0 for /f "tokens=*" %%g in ('cmd /c ""%JAVA_EXE%" -classpath "%APP_HOME%\bin\*" JvmVersion"') do ( set JAVA_VERSION=%%g ) if %JAVA_VERSION% LSS $minimumJavaVersion ( echo. echo ERROR: Java $minimumJavaVersion or higher is required. echo. echo Please update Java, then try again. echo To check your Java version, run: java -version echo. echo See https://maestro.dev/blog/introducing-maestro-2-0-0 for more details. goto fail ) """.trimIndent().replace("\n", "\r\n") fun unixMinimumJavaText(minimumJavaVersion: String): String = """ JAVA_VERSION=$( "${'$'}JAVACMD" -classpath "${'$'}APP_HOME"/bin/*.jar JvmVersion ) if [ "${'$'}JAVA_VERSION" -lt $minimumJavaVersion ]; then die "ERROR: Java $minimumJavaVersion or higher is required. Please update Java, then try again. To check your Java version, run: java -version See https://maestro.dev/blog/introducing-maestro-2-0-0 for more details." fi """.trimIndent() tasks.named("startScripts") { classpath = files("${layout.buildDirectory}/libs/*") doLast { val minimumJavaVersion = "17" val unixExec = "exec \"\$JAVACMD\" \"$@\"" val currentUnix = unixScript.readText() val replacedUnix = currentUnix.replaceFirst(unixExec, unixMinimumJavaText(minimumJavaVersion) + "\n\n" + unixExec) unixScript.writeText(replacedUnix) val currentWindows = windowsScript.readText() val windowsExec = "@rem Execute maestro" val replacedWindows = currentWindows.replaceFirst(windowsExec, windowsMinimumJavaText(minimumJavaVersion) + "\r\n\r\n" + windowsExec) windowsScript.writeText(replacedWindows) val path = project.projectDir.toPath().resolve("jvm-version.jar") copy { from(path) into(outputDir) } } } dependencies { implementation(project(path = ":maestro-utils")) annotationProcessor(libs.picocli.codegen) implementation(project(":maestro-orchestra")) implementation(project(":maestro-client")) implementation(project(":maestro-ios")) implementation(project(":maestro-ios-driver")) implementation(project(":maestro-studio:server")) implementation(libs.apk.parser) implementation(libs.dd.plist) implementation(libs.posthog) implementation(libs.dadb) implementation(libs.picocli) implementation(libs.jackson.core.databind) implementation(libs.jackson.module.kotlin) implementation(libs.jackson.dataformat.yaml) implementation(libs.jackson.dataformat.xml) implementation(libs.jackson.datatype.jsr310) implementation(libs.jansi) implementation(libs.jcodec) implementation(libs.jcodec.awt) implementation(libs.square.okhttp) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.server.core) implementation(libs.ktor.server.netty) implementation(libs.ktor.server.cors) implementation(libs.ktor.server.status.pages) implementation(libs.jarchivelib) implementation(libs.commons.codec) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.html) implementation(libs.skiko.macos.arm64) implementation(libs.skiko.macos.x64) implementation(libs.skiko.linux.arm64) implementation(libs.skiko.linux.x64) implementation(libs.skiko.windows.arm64) implementation(libs.skiko.windows.x64) implementation(libs.kotlinx.serialization.json) implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.2.0") implementation(libs.mcp.kotlin.sdk) { version { branch = "steviec/kotlin-1.8" } exclude(group = "org.slf4j", module = "slf4j-simple") } implementation(libs.logging.sl4j) implementation(libs.logging.api) implementation(libs.logging.layout.template) implementation(libs.log4j.core) implementation(libs.mordant) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.mockk) testImplementation(libs.google.truth) } tasks.named("test") { useJUnitPlatform() } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { jvmToolchain(17) } tasks.named("compileKotlin", KotlinCompilationTask::class.java) { compilerOptions { freeCompilerArgs.addAll("-Xjdk-release=17") } } tasks.create("createProperties") { dependsOn("processResources") doLast { File("$buildDir/resources/main/version.properties").writer().use { w -> val p = Properties() p["version"] = CLI_VERSION p.store(w, null) } } } tasks.register("createTestResources") { from("../maestro-ios-xctest-runner") { into("driver/ios") include( "maestro-driver-ios/**", "maestro-driver-iosUITests/**", "maestro-driver-ios.xcodeproj/**" ) } into(layout.buildDirectory.dir("resources/test")) } tasks.named("classes") { dependsOn("createTestResources") dependsOn("createProperties") } tasks.named("distZip") { archiveFileName.set("maestro.zip") } tasks.named("distTar") { archiveFileName.set("maestro.tar") } tasks.shadowJar { setProperty("zip64", true) } mavenPublishing { publishToMavenCentral(true) signAllPublications() } jreleaser { version = CLI_VERSION gitRootSearch.set(true) project { name.set("Maestro CLI") description.set("The easiest way to automate UI testing for your mobile app") links { homepage.set("https://maestro.mobile.dev") bugTracker.set("https://github.com/mobile-dev-inc/maestro/issues") } authors.set(listOf("Dmitry Zaytsev", "Amanjeet Singh", "Leland Takamine", "Arthur Saveliev", "Axel Niklasson", "Berik Visschers")) license.set("Apache-2.0") copyright.set("mobile.dev 2024") } distributions { create("maestro") { stereotype.set(Stereotype.CLI) executable { name.set("maestro") } artifact { setPath("build/distributions/maestro.zip") } release { github { repoOwner.set("mobile-dev-inc") name.set("maestro") tagName.set("cli-$CLI_VERSION") releaseName.set("CLI $CLI_VERSION") overwrite.set(true) changelog { // GitHub removes dots Markdown headers (1.37.5 becomes 1375) extraProperties.put("versionHeader", CLI_VERSION.replace(".", "")) formatted.set(ALWAYS) content.set(""" [See changelog in the CHANGELOG.md file][link] [link]: https://github.com/mobile-dev-inc/maestro/blob/main/CHANGELOG.md#{{changelogVersionHeader}} """.trimIndent() ) } } } } } packagers { brew { setActive("RELEASE") extraProperties.put("skipJava", "true") formulaName.set("Maestro") // The default template path templateDirectory.set(file("src/jreleaser/distributions/maestro/brew")) repoTap { repoOwner.set("mobile-dev-inc") name.set("homebrew-tap") } dependencies { dependency("openjdk", "17+") } } } } ================================================ FILE: maestro-cli/gradle.properties ================================================ CLI_VERSION=2.4.0 ================================================ FILE: maestro-cli/src/jreleaser/distributions/maestro/brew/formula.rb.tpl ================================================ # {{jreleaserCreationStamp}} {{#brewRequireRelative}} require_relative "{{.}}" {{/brewRequireRelative}} class {{brewFormulaName}} < Formula desc "{{projectDescription}}" homepage "{{projectLinkHomepage}}" url "{{distributionUrl}}"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}} version "{{projectVersion}}" sha256 "{{distributionChecksumSha256}}" license "{{projectLicense}}" {{#brewHasLivecheck}} livecheck do {{#brewLivecheck}} {{.}} {{/brewLivecheck}} end {{/brewHasLivecheck}} {{#brewDependencies}} depends_on {{.}} {{/brewDependencies}} def install libexec.install Dir["*"] bin.install_symlink "#{libexec}/bin/{{distributionExecutableUnix}}" => "{{distributionExecutableName}}" end test do output = shell_output("#{bin}/{{distributionExecutableName}} --version") assert_match "{{projectVersion}}", output end end ================================================ FILE: maestro-cli/src/main/java/maestro/cli/App.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli import maestro.MaestroException import maestro.cli.analytics.Analytics import maestro.cli.analytics.CliCommandRunEvent import maestro.cli.command.BugReportCommand import maestro.cli.command.ChatCommand import maestro.cli.command.CheckSyntaxCommand import maestro.cli.command.CloudCommand import maestro.cli.command.DownloadSamplesCommand import maestro.cli.command.DriverCommand import maestro.cli.command.ListCloudDevicesCommand import maestro.cli.command.ListDevicesCommand import maestro.cli.command.LoginCommand import maestro.cli.command.LogoutCommand import maestro.cli.command.McpCommand import maestro.cli.command.PrintHierarchyCommand import maestro.cli.command.QueryCommand import maestro.cli.command.RecordCommand import maestro.cli.command.StartDeviceCommand import maestro.cli.command.StudioCommand import maestro.cli.command.TestCommand import maestro.cli.insights.TestAnalysisManager import maestro.cli.update.Updates import maestro.cli.util.ChangeLogUtils import maestro.cli.util.ErrorReporter import maestro.cli.view.box import maestro.debuglog.DebugLogStore import picocli.AutoComplete.GenerateCompletion import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Option import java.util.* import kotlin.system.exitProcess @Command( name = "maestro", subcommands = [ TestCommand::class, CloudCommand::class, RecordCommand::class, PrintHierarchyCommand::class, QueryCommand::class, DownloadSamplesCommand::class, LoginCommand::class, LogoutCommand::class, BugReportCommand::class, StudioCommand::class, StartDeviceCommand::class, ListDevicesCommand::class, ListCloudDevicesCommand::class, GenerateCompletion::class, ChatCommand::class, CheckSyntaxCommand::class, DriverCommand::class, McpCommand::class, ] ) class App { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @Option(names = ["-v", "--version"], versionHelp = true, description = ["Display CLI version"]) var requestedVersion: Boolean? = false @Option(names = ["-p", "--platform"], description = ["(Optional) Select a platform to run on"]) var platform: String? = null @Option(names = ["--host"], hidden = true) var host: String? = null @Option(names = ["--port"], hidden = true) var port: Int? = null @Option( names = ["--device", "--udid"], description = ["(Optional) Device ID to run on explicitly, can be a comma separated list of IDs: --device \"Emulator_1,Emulator_2\" "], ) var deviceId: String? = null @Option(names = ["--verbose"], description = ["Enable verbose logging"]) var verbose: Boolean = false } private fun printVersion() { val props = App::class.java.classLoader.getResourceAsStream("version.properties").use { Properties().apply { load(it) } } println(props["version"]) } fun main(args: Array) { // Disable icon in Mac dock // https://stackoverflow.com/a/17544259 try { System.setProperty("apple.awt.UIElement", "true") Analytics.warnAndEnableAnalyticsIfNotDisable() Dependencies.install() Updates.fetchUpdatesAsync() val commandLine = CommandLine(App()) .setUsageHelpWidth(160) .setCaseInsensitiveEnumValuesAllowed(true) .setExecutionStrategy(DisableAnsiMixin::executionStrategy) .setExecutionExceptionHandler { ex, cmd, cmdParseResult -> runCatching { ErrorReporter.report(ex, cmdParseResult) } // make errors red println() cmd.colorScheme = CommandLine.Help.ColorScheme.Builder() .errors(CommandLine.Help.Ansi.Style.fg_red) .build() cmd.err.println( cmd.colorScheme.errorText(ex.message.orEmpty()) ) if ( ex !is CliError && ex !is MaestroException.UnsupportedJavaVersion && ex !is MaestroException.MissingAppleTeamId && ex !is MaestroException.IOSDeviceDriverSetupException ) { cmd.err.println("\nThe stack trace was:") cmd.err.println(ex.stackTraceToString()) } 1 } // Track CLI run if (args.isNotEmpty()) Analytics.trackEvent(CliCommandRunEvent(command = args[0])) val generateCompletionCommand = commandLine.subcommands["generate-completion"] generateCompletionCommand?.commandSpec?.usageMessage()?.hidden(true) val exitCode = commandLine .execute(*args) DebugLogStore.finalizeRun() TestAnalysisManager.maybeNotify() val newVersion = Updates.checkForUpdates() if (newVersion != null) { Updates.fetchChangelogAsync() System.err.println() val changelog = Updates.getChangelog() val anchor = newVersion.toString().replace(".", "") System.err.println( listOf( "A new version of the Maestro CLI is available ($newVersion).\n", "See what's new:", "https://github.com/mobile-dev-inc/maestro/blob/main/CHANGELOG.md#$anchor", ChangeLogUtils.print(changelog), "Upgrade command:", "curl -Ls \"https://get.maestro.mobile.dev\" | bash", ).joinToString("\n").box() ) } if (commandLine.isVersionHelpRequested) { printVersion() Analytics.close() exitProcess(0) } Analytics.close() exitProcess(exitCode) } catch (e: Throwable) { Analytics.close() throw e } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/CliError.kt ================================================ package maestro.cli class CliError(override val message: String) : RuntimeException(message) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/Dependencies.kt ================================================ package maestro.cli import maestro.cli.util.Unpacker.binaryDependency import maestro.cli.util.Unpacker.unpack object Dependencies { private val appleSimUtils = binaryDependency("applesimutils") fun install() { unpack( jarPath = "deps/applesimutils", target = appleSimUtils, ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/DisableAnsiMixin.kt ================================================ package maestro.cli import org.fusesource.jansi.Ansi import org.fusesource.jansi.AnsiConsole import org.fusesource.jansi.internal.CLibrary import picocli.CommandLine class DisableAnsiMixin { @CommandLine.Option( names = ["--no-color", "--no-ansi"], negatable = true, description = ["Enable / disable colors and ansi output"] ) var enableANSIOutput = true companion object { var ansiEnabled = true private set fun executionStrategy(parseResult: CommandLine.ParseResult): Int { applyCLIMixin(parseResult) return CommandLine.RunLast().execute(parseResult) } private fun findFirstParserWithMatchedParamLabel(parseResult: CommandLine.ParseResult, paramLabel: String): CommandLine.ParseResult? { val found = parseResult.matchedOptions().find { it.paramLabel() == paramLabel } if (found != null) { return parseResult } parseResult.subcommands().forEach { return findFirstParserWithMatchedParamLabel(it, paramLabel) ?: return@forEach } return null } private fun applyCLIMixin(parseResult: CommandLine.ParseResult) { // Find the first mixin for which of the enable-ansi parameter was specified val parserWithANSIOption = findFirstParserWithMatchedParamLabel(parseResult, "") val mixin = parserWithANSIOption?.commandSpec()?.mixins()?.values?.firstNotNullOfOrNull { it.userObject() as? DisableAnsiMixin } val stdoutIsTTY = CLibrary.isatty(CLibrary.STDOUT_FILENO) != 0 ansiEnabled = mixin?.enableANSIOutput // Use the param value if it was specified ?: stdoutIsTTY // Otherwise fall back to checking if output is a tty Ansi.setEnabled(ansiEnabled) if (ansiEnabled) { AnsiConsole.systemInstall() } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/ShowHelpMixin.kt ================================================ package maestro.cli import picocli.CommandLine class ShowHelpMixin { @CommandLine.Option( names = ["-h", "--help"], usageHelp = true, description = ["Display help message"], ) var help = false } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/analytics/Analytics.kt ================================================ package maestro.cli.analytics import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.posthog.server.PostHog import com.posthog.server.PostHogConfig import com.posthog.server.PostHogInterface import maestro.auth.ApiKey import maestro.cli.api.ApiClient import maestro.cli.util.EnvUtils import org.slf4j.LoggerFactory import java.nio.file.Path import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.String object Analytics : AutoCloseable { private const val POSTHOG_API_KEY: String = "phc_XKhdIS7opUZiS58vpOqbjzgRLFpi0I6HU2g00hR7CVg" private const val POSTHOG_HOST: String = "https://us.i.posthog.com" private const val DISABLE_ANALYTICS_ENV_VAR = "MAESTRO_CLI_NO_ANALYTICS" private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) private val apiClient = ApiClient(EnvUtils.BASE_API_URL) private val posthog: PostHogInterface = PostHog.with( PostHogConfig.builder(POSTHOG_API_KEY) .host(POSTHOG_HOST) .build() ) private val logger = LoggerFactory.getLogger(Analytics::class.java) private val analyticsStatePath: Path = EnvUtils.xdgStateHome().resolve("analytics.json") private val analyticsStateManager = AnalyticsStateManager(analyticsStatePath) // Simple executor for analytics events - following ErrorReporter pattern private val executor = Executors.newCachedThreadPool { Executors.defaultThreadFactory().newThread(it).apply { isDaemon = true } } private val analyticsDisabledWithEnvVar: Boolean get() = System.getenv(DISABLE_ANALYTICS_ENV_VAR) != null val hasRunBefore: Boolean get() = analyticsStateManager.hasRunBefore() val uuid: String get() = analyticsStateManager.getState().uuid /** * Super properties to be sent with the event */ private val superProperties = SuperProperties.create() /** * Call initially just to inform user and set a default state */ fun warnAndEnableAnalyticsIfNotDisable() { if (hasRunBefore) return val analyticsShouldBeEnabled = !analyticsDisabledWithEnvVar if (analyticsShouldBeEnabled) println("Anonymous analytics enabled. To opt out, set $DISABLE_ANALYTICS_ENV_VAR environment variable to any value before running Maestro.\n") analyticsStateManager.saveInitialState(granted = analyticsShouldBeEnabled, uuid = uuid) } /** * Identify user in PostHog and update local state. * * This function: * 1. Sends user identification to PostHog analytics * 2. Updates local analytics state with user info * 3. Tracks login event for analytics * * Should only be called when user identity changes (login/logout). */ fun identifyAndUpdateState(token: String) { try { val user = apiClient.getUser(token) val org = apiClient.getOrg(token) // Update local state with user info val updatedAnalyticsState = analyticsStateManager.updateState(token, user, org) val identifyProperties = UserProperties.fromAnalyticsState(updatedAnalyticsState).toMap() // Send identification to PostHog posthog.identify(analyticsStateManager.getState().uuid, identifyProperties) // Track user authentication event val isFirstAuth = analyticsStateManager.getState().cachedToken == null trackEvent(UserAuthenticatedEvent( isFirstAuth = isFirstAuth, authMethod = "oauth" )) } catch (e: Exception) { // Analytics failures should never break CLI functionality or show errors to users logger.trace("Failed to identify user: ${e.message}", e) } } /** * Conditionally identify user based on current and cashed token */ fun identifyUserIfNeeded() { // No identification needed if token is null val token = ApiKey.getToken() ?: return val cachedToken = analyticsStateManager.getState().cachedToken // No identification needed if token is same as cachedToken if (!cachedToken.isNullOrEmpty() && (token == cachedToken)) return // Else Update identification identifyAndUpdateState(token) } /** * Track events asynchronously to prevent blocking CLI operations * Use this for important events like authentication, errors, test results, etc. * This method is "fire and forget" - it will never block the calling thread */ fun trackEvent(event: PostHogEvent) { executor.submit { try { if (!analyticsStateManager.getState().enabled || analyticsDisabledWithEnvVar) return@submit identifyUserIfNeeded() // Include super properties in each event since PostHog Java client doesn't have register val eventData = convertEventToEventData(event) val userState = analyticsStateManager.getState() val groupProperties = userState.orgId?.let { orgId -> mapOf( "\$groups" to mapOf( "company" to orgId ) ) } ?: emptyMap() val properties = eventData.properties + superProperties.toMap() + UserProperties.fromAnalyticsState(userState).toMap() + groupProperties // Send Event posthog.capture( uuid, eventData.eventName, properties ) } catch (e: Exception) { // Analytics failures should never break CLI functionality logger.trace("Failed to track event ${event.name}: ${e.message}", e) } } } /** * Flush pending PostHog events immediately * Use this when you need to ensure events are sent before continuing */ fun flush() { try { posthog.flush() } catch (e: Exception) { // Analytics failures should never break CLI functionality or show errors to users logger.trace("Failed to flush PostHog: ${e.message}", e) } } /** * Convert a PostHogEvent to EventData with eventName and properties separated * This allows for clean destructuring in the calling code */ private fun convertEventToEventData(event: PostHogEvent): EventData { return try { // Use Jackson to convert the data class to a Map val jsonString = JSON.writeValueAsString(event) val eventMap = JSON.readValue(jsonString, Map::class.java) as Map // Extract the name and create properties without it val eventName = event.name val properties = eventMap.filterKeys { it != "name" } EventData(eventName, properties) } catch (e: Exception) { // Analytics failures should never break CLI functionality or show errors to users logger.trace("Failed to serialize event ${event.name}: ${e.message}", e) EventData(event.name, mapOf()) } } /** * Close and cleanup resources * Ensures pending analytics events are sent before shutdown */ override fun close() { // First, flush any pending PostHog events before shutting down threads flush() // Now shutdown PostHog to cleanup resources try { posthog.close() } catch (e: Exception) { // Analytics failures should never break CLI functionality or show errors to users logger.trace("Failed to close PostHog: ${e.message}", e) } // Now shutdown the executor try { executor.shutdown() if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { // Analytics failures should never break CLI functionality or show errors to users logger.trace("Analytics executor did not shutdown gracefully, forcing shutdown") executor.shutdownNow() } } catch (e: InterruptedException) { executor.shutdownNow() Thread.currentThread().interrupt() } } } /** * Data class to hold event name and properties for destructuring */ data class EventData( val eventName: String, val properties: Map ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/analytics/AnalyticsStateManager.kt ================================================ package maestro.cli.analytics import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import maestro.cli.api.OrgResponse import maestro.cli.api.UserResponse import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import org.slf4j.LoggerFactory import java.nio.file.Path import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.util.* import kotlin.String import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText @JsonIgnoreProperties(ignoreUnknown = true) data class AnalyticsState( val uuid: String, val enabled: Boolean, val cachedToken: String? = null, val lastUploadedForCLI: String? = null, @JsonFormat(shape = JsonFormat.Shape.STRING, timezone = "UTC") val lastUploadedTime: Instant?, val email: String? = null, val user_id: String? = null, val name: String? = null, val workOSOrgId: String? = null, val orgId: String? = null, val orgName: String? = null, val orgPlan: String? = null, val orgTrialExpiresOn: String? = null, // Org status properties val orgStatus: OrgStatus? = null, val currentPlan: OrgPlans? = null, val isInTrial: Boolean? = null, val daysUntilTrialExpiry: Int? = null, val daysUntilGracePeriodExpiry: Int? = null ) /** * Manages analytics state persistence and caching. * Separated from Analytics object to improve separation of concerns. */ class AnalyticsStateManager( private val analyticsStatePath: Path ) { private val logger = LoggerFactory.getLogger(AnalyticsStateManager::class.java) private val JSON = jacksonObjectMapper().apply { registerModule(JavaTimeModule()) enable(SerializationFeature.INDENT_OUTPUT) } private var _analyticsState: AnalyticsState? = null fun getState(): AnalyticsState { if (_analyticsState == null) { _analyticsState = loadState() } return _analyticsState!! } fun hasRunBefore(): Boolean { return analyticsStatePath.exists() } fun updateState( token: String, user: UserResponse, org: OrgResponse, ): AnalyticsState { val currentState = getState() val updatedState = currentState.copy( cachedToken = token, lastUploadedForCLI = EnvUtils.CLI_VERSION?.toString(), lastUploadedTime = Instant.now(), user_id = user.id, email = user.email, name = user.name, workOSOrgId = user.workOSOrgId, orgId = org.id, orgName = org.name, orgPlan = org.metadata?.get("pricing_plan"), ).addOrgStatusProperties(org) saveState(updatedState) return updatedState } fun saveInitialState( granted: Boolean, uuid: String? = null, ): AnalyticsState { val state = AnalyticsState( uuid = uuid ?: generateUUID(), enabled = granted, lastUploadedTime = null ) saveState(state) return state } private fun saveState(state: AnalyticsState) { val stateJson = JSON.writeValueAsString(state) analyticsStatePath.parent.toFile().mkdirs() analyticsStatePath.writeText(stateJson + "\n") logger.trace("Saved analytics to {}, value: {}", analyticsStatePath, stateJson) // Refresh the cached state _analyticsState = state } private fun loadState(): AnalyticsState { return try { if (analyticsStatePath.exists()) { JSON.readValue(analyticsStatePath.readText()) } else { createDefaultState() } } catch (e: Exception) { logger.warn("Failed to read analytics state: ${e.message}. Using default.") createDefaultState() } } private fun createDefaultState(): AnalyticsState { return AnalyticsState( uuid = generateUUID(), enabled = false, lastUploadedTime = null, ) } private fun generateUUID(): String { return CiUtils.getCiProvider() ?: UUID.randomUUID().toString() } } /** * Extension function to add organization status to AnalyticsState */ fun AnalyticsState.addOrgStatusProperties(org: OrgResponse?): AnalyticsState { if (org == null) return this val orgStatus = getOrgStatus(org) val pricingPlan = org.metadata?.get("pricing_plan") // Trial status requires checking both plan type and org status val isInTrial = pricingPlan == "BASIC" && orgStatus == OrgStatus.ACTIVE && org.metadata["trial_expires_on"] != null return this.copy( orgStatus = orgStatus, currentPlan = pricingPlan?.let { OrgPlans.valueOf(it) }, isInTrial = isInTrial, daysUntilTrialExpiry = if (isInTrial) calculateDaysUntil(org.metadata["trial_expires_on"]) else null, daysUntilGracePeriodExpiry = if (orgStatus == OrgStatus.IN_GRACE_PERIOD) calculateDaysUntil(org.metadata?.get("subscription_grace_period")) else null ) } /** * Helper function to get organization status */ private fun getOrgStatus(org: OrgResponse?): OrgStatus? { if (org == null) return null if (org.metadata == null) return null val trialExpirationDate = org.metadata.get("trial_expires_on") val pricingPlan = org.metadata.get("pricing_plan") val gracePeriod = org.metadata.get("subscription_grace_period") if (gracePeriod != null) { val graceDate = parseDate(gracePeriod) if (graceDate != null) { val now = LocalDate.now() // If grace period is in the past, expired return if (graceDate.isBefore(now)) { OrgStatus.GRACE_PERIOD_EXPIRED } else { OrgStatus.IN_GRACE_PERIOD } } else { // If we can't parse the date, assume it's active return OrgStatus.IN_GRACE_PERIOD } } if (pricingPlan == "BASIC" && trialExpirationDate != null) { val trialDate = parseDate(trialExpirationDate) if (trialDate != null) { val now = LocalDate.now() if (trialDate.isBefore(now)) { return OrgStatus.TRIAL_EXPIRED } else { return OrgStatus.ACTIVE } } else { // If we can't parse the date, assume trial is active return OrgStatus.ACTIVE } } if (pricingPlan == "BASIC") { return OrgStatus.TRIAL_NOT_ACTIVE } if (listOf("CLOUD_MANUAL", "ENTERPRISE", "CLOUD").contains(pricingPlan)) { return OrgStatus.ACTIVE } return OrgStatus.TRIAL_NOT_ACTIVE } /** * Helper function to parse dates in multiple formats */ private fun parseDate(dateString: String?): LocalDate? { if (dateString == null) return null val formatters = listOf( DateTimeFormatter.ISO_LOCAL_DATE, // "2030-02-19" DateTimeFormatter.ofPattern("MMM d yyyy", Locale.ENGLISH), // "Sep 1 2025" DateTimeFormatter.ofPattern("MMM dd yyyy", Locale.ENGLISH), // "Sep 01 2025" (with zero-padded day) DateTimeFormatter.ofPattern("MMMM d yyyy", Locale.ENGLISH), // "September 1 2025" DateTimeFormatter.ofPattern("MMMM dd yyyy", Locale.ENGLISH), // "September 01 2025" (with zero-padded day) DateTimeFormatter.ofPattern("MM/dd/yyyy"), // "02/19/2030" DateTimeFormatter.ofPattern("dd/MM/yyyy"), // "19/02/2030" DateTimeFormatter.ofPattern("yyyy-MM-dd"), // "2030-02-19" ) for (formatter in formatters) { try { return LocalDate.parse(dateString, formatter) } catch (e: DateTimeParseException) { // Try next formatter } } return null } /** * Helper function to calculate days until a date */ private fun calculateDaysUntil(dateString: String?): Int? { if (dateString == null) return null val targetDate = parseDate(dateString) ?: return null val now = LocalDate.now() return java.time.temporal.ChronoUnit.DAYS.between(now, targetDate).toInt() } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/analytics/PostHogEvents.kt ================================================ package maestro.cli.analytics import maestro.cli.model.FlowStatus import maestro.cli.util.EnvUtils import maestro.cli.util.IOSEnvUtils import maestro.device.util.AndroidEnvUtils /** * Organization status enum */ enum class OrgStatus(val value: String) { TRIAL_EXPIRED("TRIAL_EXPIRED"), GRACE_PERIOD_EXPIRED("GRACE_PERIOD_EXPIRED"), ACTIVE("ACTIVE"), TRIAL_NOT_ACTIVE("TRIAL_NOT_ACTIVE"), IN_GRACE_PERIOD("IN_GRACE_PERIOD"); } enum class OrgPlans(val value: String) { BASIC("BASIC"), CLOUD("CLOUD"), CLOUD_MANUAL("CLOUD_MANUAL"), ENTERPRISE("ENTERPRISE"), } /** * Strongly-typed PostHog events for Maestro CLI using discriminated unions * This ensures compile-time type safety for all analytics events */ /** * Super properties that are automatically included with every event */ data class SuperProperties( val app_version: String, val platform: String, val env: String, val app: String = "cli", val cli_version: String, val java_version: String, val os_arch: String, val os_version: String, val xcode_version: String? = null, val flutter_version: String? = null, val flutter_channel: String? = null, val android_versions: List? = null, val ios_versions: List? = null, ) { /** * Convert to Map for analytics tracking */ fun toMap(): Map { return mapOf( "app_version" to app_version, "platform" to platform, "env" to env, "app" to app, "cli_version" to cli_version, "java_version" to java_version, "os_arch" to os_arch, "os_version" to os_version, "xcode_version" to xcode_version, "flutter_version" to flutter_version, "flutter_channel" to flutter_channel, "android_versions" to android_versions, "ios_versions" to ios_versions ) as Map } /** * Create SuperProperties with current system information */ companion object { fun create(): SuperProperties { return SuperProperties( app_version = EnvUtils.getVersion().toString(), platform = EnvUtils.OS_NAME, env = if (System.getenv("MAESTRO_API_URL") != null) "dev" else "prod", app = "cli", cli_version = EnvUtils.CLI_VERSION.toString(), java_version = EnvUtils.getJavaVersion().toString(), os_arch = EnvUtils.OS_ARCH, os_version = EnvUtils.OS_VERSION, xcode_version = IOSEnvUtils.xcodeVersion, flutter_version = EnvUtils.getFlutterVersionAndChannel().first, flutter_channel = EnvUtils.getFlutterVersionAndChannel().second, android_versions = AndroidEnvUtils.androidEmulatorSdkVersions, ios_versions = IOSEnvUtils.simulatorRuntimes ) } } } /** * User properties for user identification */ data class UserProperties( val user_id: String?, val email: String?, val name: String?, val organizationId: String? = null, val org_id: String?, val org_name: String?, val plan: String?, val orgPlan: String?, val orgTrialExpiresOn: String?, ) { /** * Convert to Map for analytics tracking */ fun toMap(): Map { return mapOf( "user_id" to user_id, "email" to email, "name" to name, "organizationId" to organizationId, "org_id" to org_id, "org_name" to org_name, "plan" to plan, "orgPlan" to orgPlan, "orgTrialExpiresOn" to orgTrialExpiresOn ) as Map } /** * Create UserProperties from AnalyticsState */ companion object { fun fromAnalyticsState(state: AnalyticsState): UserProperties { return UserProperties( user_id = state.user_id, email = state.email, name = state.name, organizationId = state.orgId, org_id = state.orgId, org_name = state.orgName, plan = state.orgPlan, orgPlan = state.orgPlan, orgTrialExpiresOn = state.orgTrialExpiresOn, ) } } } /** * Base interface for all PostHog events */ sealed interface PostHogEvent { val name: String } /** * CLI Usage Events */ sealed interface CliUsageEvent : PostHogEvent data class CliCommandRunEvent( override val name: String = "maestro_cli_command_run", val command: String ) : CliUsageEvent /** * Test execution events */ sealed interface TestRunEvent : PostHogEvent data class TestRunStartedEvent( override val name: String = "test_run_started", val platform: String, ) : TestRunEvent data class TestRunFailedEvent( override val name: String = "test_run_failed", val error: String, val platform: String, ) : TestRunEvent data class TestRunFinishedEvent( override val name: String = "test_run_finished", val status: FlowStatus, val platform: String, val durationMs: Long ) : TestRunEvent /** * Workspace execution events */ sealed interface WorkspaceRunEvent : PostHogEvent data class WorkspaceRunStartedEvent( override val name: String = "workspace_run_started", val flowCount: Int, val platform: String, val deviceCount: Int ) : WorkspaceRunEvent data class WorkspaceRunFailedEvent( override val name: String = "workspace_run_failed", val error: String, val flowCount: Int, val platform: String, val deviceCount: Int, ) : WorkspaceRunEvent data class WorkspaceRunFinishedEvent( override val name: String = "workspace_run_finished", val flowCount: Int, val platform: String, val deviceCount: Int, val durationMs: Long ) : WorkspaceRunEvent /** * Record Screen Event */ sealed interface RecordEvent : PostHogEvent data class RecordStartedEvent( override val name: String = "maestro_cli_record_start", val platform: String, ) : RecordEvent data class RecordFinishedEvent( override val name: String = "maestro_cli_record_finished", val platform: String, val durationMs: Long ) : RecordEvent /** * Cloud Upload Events */ sealed interface CloudUploadEvent : PostHogEvent data class CloudUploadTriggeredEvent( override val name: String = "cloud_upload_triggered", val projectId: String, val isBinaryUpload: Boolean = false, val usesEnvironment: Boolean = false, val deviceModel: String? = null, val deviceOs: String? = null ) : CloudUploadEvent data class CloudUploadStartedEvent( override val name: String = "cloud_upload_started", val projectId: String, val isBinaryUpload: Boolean = false, val usesEnvironment: Boolean = false, val platform: String, val deviceModel: String? = null, val deviceOs: String? = null ) : CloudUploadEvent data class CloudUploadSucceededEvent( override val name: String = "cloud_upload_succeeded", val projectId: String, val platform: String, val isBinaryUpload: Boolean, val usesEnvironment: Boolean, val deviceModel: String? = null, val deviceOs: String? = null, ) : CloudUploadEvent /** * Cloud Run Event */ sealed interface CloudRunEvent : PostHogEvent data class CloudRunFinishedEvent( override val name: String = "cloud_run_finished", val projectId: String, val appPackageId: String, val totalFlows: Number, val totalPassedFlows: Number, val totalFailedFlows: Number, val wasAppLaunched: Boolean ) : CloudRunEvent /** * User Auth Event */ sealed interface AuthEvent : PostHogEvent data class UserAuthenticatedEvent( override val name: String = "user_authenticated", val isFirstAuth: Boolean, val authMethod: String, ) : AuthEvent data class UserLoggedOutEvent( override val name: String = "user_logged_out", ) : AuthEvent /** * Print Hierarchy Events */ sealed interface PrintHierarchyEvent : PostHogEvent data class PrintHierarchyStartedEvent( override val name: String = "print_hierarchy_started", val platform: String ) : PrintHierarchyEvent data class PrintHierarchyFinishedEvent( override val name: String = "print_hierarchy_finished", val platform: String, val success: Boolean, val durationMs: Long, val errorMessage: String? = null ) : PrintHierarchyEvent /** * Trial Events */ sealed interface TrialEvent : PostHogEvent data class TrialStartPromptedEvent( override val name: String = "trial_start_prompted", ) : TrialEvent data class TrialStartedEvent( override val name: String = "trial_started", val companyName: String, ) : TrialEvent data class TrialStartFailedEvent( override val name: String = "trial_start_failed", val companyName: String, val failureReason: String, ) : TrialEvent ================================================ FILE: maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt ================================================ package maestro.cli.api import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import maestro.cli.CliError import maestro.cli.analytics.Analytics import maestro.cli.analytics.TrialStartedEvent import maestro.cli.analytics.TrialStartFailedEvent import maestro.cli.analytics.TrialStartPromptedEvent import maestro.cli.insights.AnalysisDebugFiles import maestro.cli.model.FlowStatus import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils import maestro.cli.view.brightRed import maestro.cli.view.cyan import maestro.cli.view.green import maestro.utils.HttpClient import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink import okio.ForwardingSink import okio.IOException import okio.buffer import java.io.File import java.nio.file.Path import java.util.Scanner import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.time.Duration.Companion.minutes class ApiClient( private val baseUrl: String, ) { private val client = HttpClient.build( name = "ApiClient", readTimeout = 5.minutes, writeTimeout = 5.minutes, protocols = listOf(Protocol.HTTP_1_1), interceptors = listOf(SystemInformationInterceptor()), ) val domain: String get() { val regex = "https?://[^.]+.([a-zA-Z0-9.-]*).*".toRegex() val matchResult = regex.matchEntire(baseUrl) val domain = if (!matchResult?.groups?.get(1)?.value.isNullOrEmpty()) { matchResult?.groups?.get(1)?.value } else { matchResult?.groups?.get(0)?.value } return domain ?: "mobile.dev" } fun sendErrorReport(exception: Exception, commandLine: String) { post( path = "/maestro/error", body = mapOf( "exception" to exception, "commandLine" to commandLine ) ) } fun sendScreenReport(maxDepth: Int) { post( path = "/maestro/screen", body = mapOf( "maxDepth" to maxDepth ) ) } fun getLatestCliVersion(): CliVersion { val request = Request.Builder() .header("X-FRESH-INSTALL", if (!Analytics.hasRunBefore) "true" else "false") .url("$baseUrl/v2/maestro/version") .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } return JSON.readValue(response.body?.bytes(), CliVersion::class.java) } } fun getAuthUrl(port: String): String { return "$baseUrl/v2/maestroLogin/authUrl?port=$port" } fun exchangeToken(code: String): String { val requestBody = code.toRequestBody("text/plain".toMediaType()) val request = Request.Builder() .url("$baseUrl/v2/maestroLogin/exchange") .post(requestBody) .build() try { client.newCall(request).execute().use { response -> val responseBody = response.body?.string() println(responseBody ?: "No response body received") if (!response.isSuccessful) { throw IOException("HTTP ${response.code}: ${response.message}\nBody: $responseBody") } return responseBody ?: throw IOException("Empty response body") } } catch (e: Exception) { throw IOException("${e.message}", e) } } fun isAuthTokenValid(authToken: String): Boolean { val request = Request.Builder() .url("$baseUrl/v2/maestroLogin/valid") .header("Authorization", "Bearer $authToken") .get() .build() client.newCall(request).execute().use { response -> return !(!response.isSuccessful && (response.code == 401 || response.code == 403)) } } private fun getAgent(): String { return CiUtils.getCiProvider() ?: "cli" } fun uploadStatus( authToken: String, uploadId: String, projectId: String?, ): UploadStatus { val baseUrl = "$baseUrl/v2/project/$projectId/upload/$uploadId" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } return JSON.readValue(response.body?.bytes(), UploadStatus::class.java) } } fun render( screenRecording: File, frames: List, progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> }, ): String { val baseUrl = "https://maestro-record.ngrok.io" val body = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "screenRecording", screenRecording.name, screenRecording.asRequestBody("application/mp4".toMediaType()).observable(progressListener) ) .addFormDataPart("frames", JSON.writeValueAsString(frames)) .build() val request = Request.Builder() .url("$baseUrl/render") .post(body) .build() val response = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw CliError("Render request failed (${response.code}): ${response.body?.string()}") } JSON.readValue(response.body?.bytes(), RenderResponse::class.java) } return response.id } fun getRenderState(id: String): RenderState { val baseUrl = "https://maestro-record.ngrok.io" val request = Request.Builder() .url("$baseUrl/v2/render/$id") .get() .build() val response = client.newCall(request).execute().use { response -> if (!response.isSuccessful) { throw CliError("Get render state request failed (${response.code}): ${response.body?.string()}") } JSON.readValue(response.body?.bytes(), RenderState::class.java) } val downloadUrl = if (response.downloadUrl == null) null else "$baseUrl${response.downloadUrl}" return response.copy(downloadUrl = downloadUrl) } fun upload( authToken: String, appFile: Path?, workspaceZip: Path, uploadName: String?, mappingFile: Path?, repoOwner: String?, repoName: String?, branch: String?, commitSha: String?, pullRequestId: String?, env: Map? = null, appBinaryId: String? = null, includeTags: List = emptyList(), excludeTags: List = emptyList(), maxRetryCount: Int = 3, completedRetries: Int = 0, disableNotifications: Boolean, deviceLocale: String? = null, progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> }, projectId: String, deviceModel: String? = null, deviceOs: String? = null, androidApiLevel: Int?, iOSVersion: 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()}") if (!workspaceZip.exists()) throw CliError("Workspace zip does not exist: ${workspaceZip.absolutePathString()}") val requestPart = mutableMapOf() if (uploadName != null) { requestPart["benchmarkName"] = uploadName } repoOwner?.let { requestPart["repoOwner"] = it } repoName?.let { requestPart["repoName"] = it } branch?.let { requestPart["branch"] = it } commitSha?.let { requestPart["commitSha"] = it } pullRequestId?.let { requestPart["pullRequestId"] = it } env?.let { requestPart["env"] = it } requestPart["agent"] = getAgent() appBinaryId?.let { requestPart["appBinaryId"] = it } deviceLocale?.let { requestPart["deviceLocale"] = it } requestPart["projectId"] = projectId deviceModel?.let { requestPart["deviceModel"] = it } deviceOs?.let { requestPart["deviceOs"] = it } if (includeTags.isNotEmpty()) requestPart["includeTags"] = includeTags if (excludeTags.isNotEmpty()) requestPart["excludeTags"] = excludeTags if (disableNotifications) requestPart["disableNotifications"] = true val bodyBuilder = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "workspace", "workspace.zip", workspaceZip.toFile().asRequestBody("application/zip".toMediaType()) ) .addFormDataPart("request", JSON.writeValueAsString(requestPart)) if (appFile != null) { bodyBuilder.addFormDataPart( "app_binary", "app.zip", appFile.toFile().asRequestBody("application/zip".toMediaType()).observable(progressListener) ) } if (mappingFile != null) { bodyBuilder.addFormDataPart( "mapping", "mapping.txt", mappingFile.toFile().asRequestBody("text/plain".toMediaType()) ) } val body = bodyBuilder.build() fun retry(message: String, e: Throwable? = null): UploadResponse { if (completedRetries >= maxRetryCount) { e?.printStackTrace() throw CliError(message) } PrintUtils.message("$message, retrying (${completedRetries + 1}/$maxRetryCount)...") Thread.sleep(BASE_RETRY_DELAY_MS + (2000 * completedRetries)) return upload( authToken = authToken, appFile = appFile, workspaceZip = workspaceZip, uploadName = uploadName, mappingFile = mappingFile, repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, env = env, includeTags = includeTags, excludeTags = excludeTags, maxRetryCount = maxRetryCount, completedRetries = completedRetries + 1, progressListener = progressListener, appBinaryId = appBinaryId, disableNotifications = disableNotifications, deviceLocale = deviceLocale, projectId = projectId, deviceModel = deviceModel, deviceOs = deviceOs, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion, ) } val url = "$baseUrl/v2/project/$projectId/runMaestroTest" val response = try { val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(body) .build() client.newCall(request).execute() } catch (e: IOException) { return retry("Upload failed due to socket exception", e) } response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" if (response.code == 403 && errorMessage.contains( "Your trial has not started yet", ignoreCase = true ) ) { Analytics.trackEvent(TrialStartPromptedEvent()) PrintUtils.info("\n[ERROR] Your trial has not started yet".brightRed()) PrintUtils.info("[INFO] Start your 7-day free trial with no credit card required!".green()) PrintUtils.info("${"[INPUT]".cyan()} Please enter your company name to start the free trial: ") val scanner = Scanner(System.`in`) val companyName = scanner.nextLine().trim() if (companyName.isNotEmpty()) { println("\u001B[33;1m[INFO]\u001B[0m Starting your trial for company: \u001B[36;1m$companyName\u001B[0m...") val isTrialStarted = startTrial(authToken, companyName); if (isTrialStarted) { println("\u001B[32;1m[SUCCESS]\u001B[0m Free trial successfully started! Enjoy your 7-day free trial!\n") return upload( authToken = authToken, appFile = appFile, workspaceZip = workspaceZip, uploadName = uploadName, mappingFile = mappingFile, repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, env = env, includeTags = includeTags, excludeTags = excludeTags, maxRetryCount = maxRetryCount, completedRetries = completedRetries + 1, progressListener = progressListener, appBinaryId = appBinaryId, disableNotifications = disableNotifications, deviceLocale = deviceLocale, projectId = projectId, deviceModel = deviceModel, deviceOs = deviceOs, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion, ) } else { println("\u001B[31;1m[ERROR]\u001B[0m Failed to start trial. Please check your details and try again.") } } else { println("\u001B[31;1m[ERROR]\u001B[0m Company name is required to start your free trial.") // Track trial start failed event for empty company name Analytics.trackEvent(TrialStartFailedEvent( companyName = "", failureReason = "EMPTY_COMPANY_NAME" )) } } if (response.code >= 500) { return retry("Upload failed with status code ${response.code}: $errorMessage") } else { throw CliError("Upload request failed (${response.code}): $errorMessage") } } val responseBody = JSON.readValue(response.body?.bytes(), Map::class.java) return parseUploadResponse(responseBody) } } private fun startTrial(authToken: String, companyName: String): Boolean { println("Starting your trial...") val url = "$baseUrl/v2/start-trial" val request = StartTrialRequest(companyName, referralSource = "cli") val jsonBody = JSON.writeValueAsString(request).toRequestBody("application/json".toMediaType()) val trialRequest = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(jsonBody) .build() try { val response = client.newCall(trialRequest).execute() if (response.isSuccessful) { Analytics.trackEvent(TrialStartedEvent(companyName = companyName)) return true } val errorMessage = response.body?.string() ?: "Unknown error" println("\u001B[31m$errorMessage\u001B[0m"); // Track trial start failed event Analytics.trackEvent(TrialStartFailedEvent( companyName = companyName, failureReason = "API_ERROR: $errorMessage" )) return false } catch (e: IOException) { println("\u001B[31;1m[ERROR]\u001B[0m We're experiencing connectivity issues, please try again in sometime, reach out to the slack channel in case if this doesn't work.") // Track trial start failed event Analytics.trackEvent(TrialStartFailedEvent( companyName = companyName, failureReason = "CONNECTIVITY_ERROR: ${e.message}" )) return false } } private fun parseUploadResponse(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 UploadResponse( orgId = orgId, uploadId = uploadId, deviceConfiguration = deviceConfiguration, appId = appId, appBinaryId = appBinaryId ) } private inline fun post(path: String, body: Any): Result { val bodyBytes = JSON.writeValueAsBytes(body) val request = Request.Builder() .post(bodyBytes.toRequestBody("application/json".toMediaType())) .url("$baseUrl$path") .build() val response = client.newCall(request).execute() if (!response.isSuccessful) return Err(response) if (Unit is T) return Ok(Unit) val parsed = JSON.readValue(response.body?.bytes(), T::class.java) return Ok(parsed) } private fun RequestBody.observable( progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit, ) = object : RequestBody() { override fun contentLength() = this@observable.contentLength() override fun contentType() = this@observable.contentType() override fun writeTo(sink: BufferedSink) { val forwardingSink = object : ForwardingSink(sink) { private var bytesWritten = 0L override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten += byteCount progressListener(contentLength(), bytesWritten) } }.buffer() progressListener(contentLength(), 0) this@observable.writeTo(forwardingSink) forwardingSink.flush() } } fun analyze( authToken: String, debugFiles: AnalysisDebugFiles, ): AnalyzeResponse { val mediaType = "application/json; charset=utf-8".toMediaType() val body = JSON.writeValueAsString(debugFiles).toRequestBody(mediaType) val url = "$baseUrl/v2/analyze" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(body) .build() val response = client.newCall(request).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" throw CliError("Analyze request failed (${response.code}): $errorMessage") } val parsed = JSON.readValue(response.body?.bytes(), AnalyzeResponse::class.java) return parsed; } } fun listCloudDevices(): Map>> { val request = Request.Builder() .url("$baseUrl/v2/device/list") .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) throw ApiException(statusCode = response.code) return JSON.readValue(response.body?.bytes(), object : TypeReference>>>() {}) } } fun botMessage(question: String, sessionId: String, authToken: String): List { val body = JSON.writeValueAsString( MessageRequest( sessionId = sessionId, context = emptyList(), messages = listOf( ContentDetail( type = "text", text = question ) ) ) ) val url = "$baseUrl/v2/bot/message" val request = Request.Builder() .url(url) .header("Authorization", "Bearer $authToken") .post(body.toRequestBody("application/json".toMediaType())) .build() val response = client.newCall(request).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown" throw CliError("bot message request failed (${response.code}): $errorMessage") } val data = response.body?.bytes() val parsed = JSON.readValue(data, object : TypeReference>() {}) return parsed; } } fun getUser(authToken: String): UserResponse { val baseUrl = "$baseUrl/v2/maestro-studio/user" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val user = JSON.readValue(responseBody, UserResponse::class.java) return user } catch (e: Exception) { throw e } } } fun getOrg(authToken: String): OrgResponse { val baseUrl = "$baseUrl/v2/maestro-studio/org" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(baseUrl) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val user = JSON.readValue(responseBody, OrgResponse::class.java) return user } catch (e: Exception) { throw e } } } fun getOrgs(authToken: String): List { val url = "$baseUrl/v2/maestro-studio/orgs" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val orgs = JSON.readValue(responseBody, object : TypeReference>() {}) return orgs } catch (e: Exception) { throw e } } } fun switchOrg(authToken: String, orgId: String): String { val url = "$baseUrl/v2/maestro-studio/org/switch" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .post(orgId.toRequestBody("text/plain".toMediaType())) .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { // The endpoint returns the API key directly as plain text return responseBody ?: throw Exception("No API key in switch org response") } catch (e: Exception) { throw e } } } fun getProjects(authToken: String): List { val url = "$baseUrl/v2/maestro-studio/projects" val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url(url) .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException( statusCode = response.code ) } val responseBody = response.body?.string() try { val projects = JSON.readValue(responseBody, object : TypeReference>() {}) return projects } catch (e: Exception) { throw e } } } fun getAppBinaryInfo(authToken: String, appBinaryId: String): AppBinaryInfo { val request = Request.Builder() .header("Authorization", "Bearer $authToken") .url("$baseUrl/v2/maestro-studio/app-binary/$appBinaryId") .get() .build() val response = try { client.newCall(request).execute() } catch (e: IOException) { throw ApiException(statusCode = null) } response.use { if (!response.isSuccessful) { throw ApiException(statusCode = response.code) } return JSON.readValue(response.body?.bytes(), AppBinaryInfo::class.java) } } data class ApiException( val statusCode: Int?, ) : 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) } } data class UploadResponse( val orgId: String, val uploadId: String, val appId: String, val deviceConfiguration: DeviceConfiguration?, val appBinaryId: String?, ) data class AppBinaryInfo( val appBinaryId: String, val platform: String, val appId: String, ) data class DeviceConfiguration( val platform: String, val deviceName: String, val orientation: String, val osVersion: String, val displayInfo: String, val deviceLocale: String? ) @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceInfo( val platform: String, val displayInfo: String, val isDefaultOsVersion: Boolean, val deviceLocale: String, ) @JsonIgnoreProperties(ignoreUnknown = true) data class UploadStatus( val uploadId: String, val status: Status, val completed: Boolean, val totalTime: Long?, val startTime: Long?, val flows: List, val appPackageId: String?, val wasAppLaunched: Boolean ) { data class FlowResult( val name: String, val status: FlowStatus, val errors: List, val startTime: Long, val totalTime: Long? = null, val cancellationReason: CancellationReason? = null ) enum class Status { PENDING, PREPARING, INSTALLING, RUNNING, SUCCESS, ERROR, CANCELED, WARNING, STOPPED } // These values must match backend monorepo models // in package models.benchmark.BenchmarkCancellationReason enum class CancellationReason { BENCHMARK_DEPENDENCY_FAILED, INFRA_ERROR, OVERLAPPING_BENCHMARK, TIMEOUT, CANCELED_BY_USER, RUN_EXPIRED, } } data class RenderResponse( val id: String, ) data class RenderState( val status: String, val positionInQueue: Int?, val currentTaskProgress: Float?, val error: String?, val downloadUrl: String?, ) data class UserResponse( val id: String, val email: String, val firstName: String?, val lastName: String?, val status: String, val role: String, val workOSOrgId: String, ) { val name: String get() = when { !firstName.isNullOrBlank() && !lastName.isNullOrBlank() -> "$firstName $lastName" !firstName.isNullOrBlank() -> firstName!! !lastName.isNullOrBlank() -> lastName!! else -> email } } data class OrgResponse( val id: String, val name: String, val quota: Map>?, val metadata: Map?, val workOSOrgId: String?, ) data class ProjectResponse( val id: String, val name: String, ) data class CliVersion( val major: Int, val minor: Int, val patch: Int, ) : Comparable { override fun compareTo(other: CliVersion): Int { return COMPARATOR.compare(this, other) } override fun toString(): String { return "$major.$minor.$patch" } companion object { private val COMPARATOR = compareBy({ it.major }, { it.minor }, { it.patch }) fun parse(versionString: String): CliVersion? { val parts = versionString.split('.') if (parts.size != 3) return null val major = parts[0].toIntOrNull() ?: return null val minor = parts[1].toIntOrNull() ?: return null val patch = parts[2].toIntOrNull() ?: return null return CliVersion(major, minor, patch) } } } class SystemInformationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder() .header("X-UUID", Analytics.uuid) .header("X-VERSION", EnvUtils.getVersion().toString()) .header("X-OS", EnvUtils.OS_NAME) .header("X-OSARCH", EnvUtils.OS_ARCH) .build() return chain.proceed(newRequest) } } data class Insight( val category: String, val reasoning: String, ) data class StartTrialRequest( val companyName: String, val referralSource: String, ) class AnalyzeResponse( val htmlReport: String?, val output: String, val insights: List ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/api/Chatbot.kt ================================================ package maestro.cli.api import com.fasterxml.jackson.annotation.JsonProperty data class MessageRequest( @JsonProperty("sessionId") val sessionId: String, val context: List, val messages: List ) data class ContentDetail( val type: String, // "text" or "image_url" for now val text: String? = null, val image_url: Base64Image? = null ) data class Base64Image( val url: String, val detail: String, ) data class MessageContent( val role: String, val content: List = emptyList(), val tool_calls: List? = null, val tool_call_id: String? = null ) data class ToolCall( val id: String, val function: ToolFunction ) data class ToolFunction( val name: String, val arguments: String?, ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/auth/Auth.kt ================================================ package maestro.cli.auth import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import maestro.auth.ApiKey import maestro.cli.api.ApiClient import maestro.cli.util.PrintUtils.err import maestro.cli.util.PrintUtils.info import maestro.cli.util.PrintUtils.success import maestro.cli.util.getFreePort import java.awt.Desktop import java.net.URI private const val SUCCESS_HTML = """ Authentication Successful

Authentication Successful!

You can close this window and return to the CLI.

""" private const val FAILURE_DEFAULT_DESCRIPTION = "Something went wrong. Please try again." private const val FAILURE_HTML = """ Authentication Failed

Authentication Failed

${FAILURE_DEFAULT_DESCRIPTION}

""" class Auth( private val apiClient: ApiClient ) { fun getAuthToken(apiKey: String?, triggerSignIn: Boolean = true): String? { if (triggerSignIn) { return apiKey // Check for API key ?: ApiKey.getToken() ?: triggerSignInFlow() // Otherwise, trigger the sign-in flow } return apiKey // Check for API key ?: ApiKey.getToken() } fun triggerSignInFlow(): String { val deferredToken = CompletableDeferred() val port = getFreePort() val server = embeddedServer(Netty, configure = { shutdownTimeout = 0; shutdownGracePeriod = 0 }, port = port) { routing { get("/callback") { handleCallback(call, deferredToken) } } }.start(wait = false) val authUrl = apiClient.getAuthUrl(port.toString()) info("Your browser has been opened to visit:\n\n\t$authUrl") if (Desktop.isDesktopSupported()) { Desktop.getDesktop().browse(URI(authUrl)) } else { err("Failed to open browser on this platform. Please open the above URL in your preferred browser.") throw UnsupportedOperationException("Failed to open browser automatically on this platform. Please open the above URL in your preferred browser.") } val token = runBlocking { deferredToken.await() } server.stop(0, 0) ApiKey.setToken(token) success("Authentication completed.") return token } private suspend fun handleCallback(call: ApplicationCall, deferredToken: CompletableDeferred) { val code = call.request.queryParameters["code"] if (code.isNullOrEmpty()) { err("No authorization code received. Please try again.") call.respondText(FAILURE_HTML, ContentType.Text.Html) return } try { val newApiKey = apiClient.exchangeToken(code) call.respondText(SUCCESS_HTML, ContentType.Text.Html) deferredToken.complete(newApiKey) } catch (e: Exception) { val errorMessage = "Failed to exchange token: ${e.message}" call.respondText( if (errorMessage.isNotBlank()) FAILURE_HTML.replace(FAILURE_DEFAULT_DESCRIPTION, errorMessage) else FAILURE_HTML, ContentType.Text.Html, status = HttpStatusCode.InternalServerError ) deferredToken.completeExceptionally(e) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt ================================================ package maestro.cli.cloud import maestro.cli.CliError import maestro.cli.analytics.Analytics import maestro.cli.analytics.CloudUploadTriggeredEvent import maestro.cli.api.ApiClient import maestro.cli.api.DeviceConfiguration import maestro.cli.api.OrgResponse import maestro.cli.api.ProjectResponse import maestro.cli.api.UploadStatus import maestro.cli.auth.Auth import maestro.device.Platform import maestro.cli.insights.AnalysisDebugFiles import maestro.cli.model.FlowStatus import maestro.cli.model.RunningFlow import maestro.cli.model.RunningFlows import maestro.cli.model.TestExecutionSummary import maestro.cli.report.HtmlInsightsAnalysisReporter import maestro.cli.report.ReportFormat import maestro.cli.report.ReporterFactory import maestro.cli.util.FileUtils.isWebFlow import maestro.cli.util.FileUtils.isZip import maestro.cli.util.PrintUtils import maestro.cli.util.WorkspaceUtils import maestro.cli.view.ProgressBar import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.input.interactiveSelectList import maestro.cli.analytics.CloudRunFinishedEvent import maestro.cli.analytics.CloudUploadSucceededEvent import maestro.cli.view.TestSuiteStatusView import maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel.Companion.toViewModel import maestro.cli.view.TestSuiteStatusView.uploadUrl import maestro.cli.view.box import maestro.cli.view.cyan import maestro.cli.view.render import maestro.cli.promotion.PromotionStateManager import maestro.orchestra.validation.AppMetadataAnalyzer import maestro.orchestra.validation.AppMetadata import maestro.cli.web.WebInteractor import maestro.orchestra.validation.AppValidationException import maestro.orchestra.validation.AppValidator import maestro.orchestra.validation.WorkspaceValidationException import maestro.orchestra.validation.WorkspaceValidator import maestro.device.DeviceSpec import maestro.device.DeviceSpecRequest import maestro.utils.TemporaryDirectory import okio.BufferedSink import okio.buffer import okio.sink import org.rauschig.jarchivelib.ArchiveFormat import org.rauschig.jarchivelib.ArchiverFactory import java.io.File import java.nio.file.Path import java.util.* import java.util.concurrent.TimeUnit import kotlin.String import kotlin.io.path.absolute import kotlin.time.Duration.Companion.milliseconds val terminalStatuses = listOf(FlowStatus.CANCELED, FlowStatus.STOPPED, FlowStatus.SUCCESS, FlowStatus.ERROR) class CloudInteractor( private val client: ApiClient, private val appFileValidator: (File) -> AppMetadata?, private val workspaceValidator: WorkspaceValidator, private val webManifestProvider: (() -> File?)? = null, private val auth: Auth = Auth(client), private val waitTimeoutMs: Long = TimeUnit.MINUTES.toMillis(30), private val minPollIntervalMs: Long = TimeUnit.SECONDS.toMillis(10), private val maxPollingRetries: Int = 5, private val failOnTimeout: Boolean = true, ) { fun upload( flowFile: File, appFile: File?, async: Boolean, mapping: File? = null, apiKey: String? = null, uploadName: String? = null, repoOwner: String? = null, repoName: String? = null, branch: String? = null, commitSha: String? = null, pullRequestId: String? = null, env: Map = emptyMap(), appBinaryId: String? = null, failOnCancellation: Boolean = false, includeTags: List = emptyList(), excludeTags: List = emptyList(), reportFormat: ReportFormat = ReportFormat.NOOP, reportOutput: File? = null, testSuiteName: String? = null, disableNotifications: Boolean = false, deviceLocale: String? = null, projectId: String? = null, deviceModel: String? = null, deviceOs: String? = null, androidApiLevel: Int? = null, iOSVersion: String? = null, ): Int { 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") // In case apiKey is provided use that, else fallback to signIn and org Selection val authToken: String = auth.getAuthToken(apiKey, triggerSignIn = false) ?: selectOrganization(auth.getAuthToken(apiKey, triggerSignIn = true) ?: throw CliError("Failed to get authentication token")) // Fetch and select project if not provided val selectedProjectId = projectId ?: selectProject(authToken) // Record cloud command usage for promotion message suppression PromotionStateManager().recordCloudCommandUsage() // Track cloud upload triggered before any file I/O; platform unknown until binary is analyzed Analytics.trackEvent(CloudUploadTriggeredEvent( projectId = selectedProjectId, isBinaryUpload = appBinaryId != null, usesEnvironment = env.isNotEmpty(), deviceModel = deviceModel, deviceOs = deviceOs )) PrintUtils.message("Uploading Flow(s)...") TemporaryDirectory.use { tmpDir -> val workspaceZip = tmpDir.resolve("workspace.zip") WorkspaceUtils.createWorkspaceZip(flowFile.toPath().absolute(), workspaceZip) val progressBar = ProgressBar(20) // Binary id or Binary file val appFileToSend = getAppFile(appFile, appBinaryId, tmpDir, flowFile) // Validate app and resolve platform val appValidator = AppValidator( appFileValidator = appFileValidator, appBinaryInfoProvider = { binaryId -> try { val info = client.getAppBinaryInfo(authToken, binaryId) AppValidator.AppBinaryInfoResult(info.appBinaryId, info.platform, info.appId) } catch (e: ApiClient.ApiException) { if (e.statusCode == 404) throw AppValidationException.AppBinaryNotFound(binaryId) throw AppValidationException.AppBinaryFetchError(e.statusCode) } }, webManifestProvider = webManifestProvider, iosMinOSVersionProvider = { file -> val metadata = AppMetadataAnalyzer.getIosAppMetadata(file) ?: return@AppValidator null val major = metadata.minimumOSVersion.substringBefore(".").toIntOrNull() ?: return@AppValidator null AppValidator.IosMinOSVersion(major = major, full = metadata.minimumOSVersion) }, ) val resolvedAppValidation = try { appValidator.validate(appFile = appFileToSend, appBinaryId = appBinaryId) } catch (e: AppValidationException) { throw CliError(e.message ?: "App validation failed") } // Fetch supported devices and validate device spec val supportedDevices = try { client.listCloudDevices() } catch (e: ApiClient.ApiException) { throw CliError("Failed to fetch supported devices. Status code: ${e.statusCode}") } // Validate workspace against appId before uploading to catch errors early try { workspaceValidator.validate( workspace = workspaceZip.toFile(), appId = resolvedAppValidation.appIdentifier, env = env, includeTags = includeTags, excludeTags = excludeTags, ) } catch (e: WorkspaceValidationException) { throw CliError(e.message ?: "Workspace validation failed") } val response = client.upload( authToken = authToken, appFile = appFileToSend?.toPath(), workspaceZip = workspaceZip, uploadName = uploadName, mappingFile = mapping?.toPath(), repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, env = env, appBinaryId = appBinaryId, includeTags = includeTags, excludeTags = excludeTags, disableNotifications = disableNotifications, projectId = selectedProjectId, progressListener = { totalBytes, bytesWritten -> progressBar.set(bytesWritten.toFloat() / totalBytes.toFloat()) }, deviceLocale = deviceLocale, deviceModel = deviceModel, deviceOs = deviceOs, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion, ) // Track finish after upload completion val platform = response.deviceConfiguration?.platform?.lowercase() ?: "unknown" Analytics.trackEvent(CloudUploadSucceededEvent( projectId = selectedProjectId, platform = platform, isBinaryUpload = appBinaryId != null, usesEnvironment = env.isNotEmpty(), deviceModel = deviceModel, deviceOs = deviceOs )) val project = requireNotNull(selectedProjectId) val appId = response.appId val uploadUrl = uploadUrl(project, appId, response.uploadId, client.domain) val deviceMessage = if (response.deviceConfiguration != null) printDeviceInfo(response.deviceConfiguration) else "" val uploadResponse = printMaestroCloudResponse( async, authToken, failOnCancellation, reportFormat, reportOutput, testSuiteName, uploadUrl, deviceMessage, appId, response.appBinaryId, response.uploadId, selectedProjectId, ) Analytics.trackEvent(CloudRunFinishedEvent( projectId = selectedProjectId, totalFlows = uploadResponse.flows.size, totalPassedFlows = uploadResponse.flows.count { it.status == FlowStatus.SUCCESS }, totalFailedFlows = uploadResponse.flows.count { it.status == FlowStatus.ERROR }, appPackageId = uploadResponse.appPackageId ?: "", wasAppLaunched = uploadResponse.wasAppLaunched )) Analytics.flush() return when (uploadResponse.status) { UploadStatus.Status.SUCCESS -> 0 UploadStatus.Status.ERROR -> 1 UploadStatus.Status.CANCELED -> if (failOnCancellation) 1 else 0 UploadStatus.Status.STOPPED -> 1 else -> 1 } } } private fun selectProject(authToken: String): String { val projects = try { client.getProjects(authToken) } catch (e: ApiClient.ApiException) { throw CliError("Failed to fetch projects. Status code: ${e.statusCode}") } catch (e: Exception) { throw CliError("Failed to fetch projects: ${e.message}") } if (projects.isEmpty()) { throw CliError("No projects found. Please create a project first at https://console.mobile.dev") } return when (projects.size) { 1 -> { val project = projects.first() PrintUtils.info("Using project: ${project.name} (${project.id})") project.id } else -> { val selectedProject = pickProject(projects) PrintUtils.info("Selected project: ${selectedProject.name} (${selectedProject.id})") selectedProject.id } } } fun pickProject(projects: List): ProjectResponse { val terminal = Terminal() val choices = projects.map { "${it.name} (${it.id})" } val selection = terminal.interactiveSelectList( choices, title = "Multiple projects found. Please select one (Bypass this prompt by using --project-id=<>):" ) if (selection == null) { terminal.println("No project selected") throw CliError("Project selection was cancelled") } val selectedIndex = choices.indexOf(selection) return projects[selectedIndex] } private fun selectOrganization(authToken: String): String { val orgs = try { client.getOrgs(authToken) } catch (e: ApiClient.ApiException) { throw CliError("Failed to fetch organizations. Status code: ${e.statusCode}") } catch (e: Exception) { throw CliError("Failed to fetch organizations: ${e.message}") } return when (orgs.size) { 1 -> { val org = orgs.first() PrintUtils.message("Using organization: ${org.name} (${org.id})") authToken } else -> { val selectedOrg = pickOrganization(orgs) PrintUtils.info("Selected organization: ${selectedOrg.name} (${selectedOrg.id})") // Switch to the selected organization to get org-scoped token try { client.switchOrg(authToken, selectedOrg.id) } catch (e: ApiClient.ApiException) { throw CliError("Failed to switch to organization. Status code: ${e.statusCode}") } catch (e: Exception) { throw CliError("Failed to switch to organization: ${e.message}") } } } } fun pickOrganization(orgs: List): OrgResponse { val terminal = Terminal() val choices = orgs.map { "${it.name} (${it.id})" } val selection = terminal.interactiveSelectList( choices, title = "Multiple organizations found. Please select one (Bypass this prompt by using --api-key=<>):", ) if (selection == null) { terminal.println("No organization selected") throw CliError("Organization selection was cancelled") } val selectedIndex = choices.indexOf(selection) return orgs[selectedIndex] } private fun getAppFile( appFile: File?, appBinaryId: String?, tmpDir: Path, flowFile: File ): File? { when { appBinaryId != null -> return null appFile != null -> if (appFile.isZip()) { return appFile } else { val archiver = ArchiverFactory.createArchiver(ArchiveFormat.ZIP) // An awkward API of Archiver that has a different behaviour depending on // whether we call a vararg method or a normal method. The *arrayOf() construct // forces compiler to choose vararg method. @Suppress("RemoveRedundantSpreadOperator") return archiver.create(appFile.name + ".zip", tmpDir.toFile(), *arrayOf(appFile.absoluteFile)) } flowFile.isWebFlow() -> return WebInteractor.createManifestFromWorkspace(flowFile) else -> return null } } 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 ): UploadStatus { if (async) { PrintUtils.message("✅ Upload successful!") println(deviceInfoMessage) PrintUtils.info("View the results of your upload below:") PrintUtils.info(uploadUrl.cyan()) if (appBinaryIdResponse != null) PrintUtils.info("App binary id: ${appBinaryIdResponse.cyan()}\n") // Return a simple UploadStatus for async case return UploadStatus( uploadId = uploadId, status = UploadStatus.Status.SUCCESS, completed = true, totalTime = null, startTime = null, flows = emptyList(), appPackageId = null, wasAppLaunched = false, ) } else { println(deviceInfoMessage) // Print the upload URL PrintUtils.info("Visit Maestro Cloud for more details about this upload:") PrintUtils.info(uploadUrl.cyan()) println() if (appBinaryIdResponse != null) PrintUtils.info("App binary id: ${appBinaryIdResponse.cyan()}\n") PrintUtils.info("Waiting for runs to be completed...") return waitForCompletion( authToken = authToken, uploadId = uploadId, appId = appId, failOnCancellation = failOnCancellation, reportFormat = reportFormat, reportOutput = reportOutput, testSuiteName = testSuiteName, uploadUrl = uploadUrl, projectId = projectId, ) } } private fun printDeviceInfo(deviceConfiguration: DeviceConfiguration): String { val platform = Platform.fromString(deviceConfiguration.platform) PrintUtils.info("\n") val version = deviceConfiguration.osVersion val lines = listOf( "Maestro cloud device specs:\n* @|magenta ${deviceConfiguration.displayInfo} - ${deviceConfiguration.deviceLocale}|@\n", "To change OS version use this option: @|magenta ${if (platform == Platform.IOS) "--device-os=" else "--android-api-level="}|@", "To change devices use this option: @|magenta --device-model=|@", "To change device locale use this option: @|magenta --device-locale=|@", "To create a similar device locally, run: @|magenta `maestro start-device --platform=${ platform.toString().lowercase() } --os-version=$version --device-locale=${deviceConfiguration.deviceLocale}`|@" ) return lines.joinToString("\n").render().box() } internal fun waitForCompletion( authToken: String, uploadId: String, appId: String, failOnCancellation: Boolean, reportFormat: ReportFormat, reportOutput: File?, testSuiteName: String?, uploadUrl: String, projectId: String? ): UploadStatus { val startTime = System.currentTimeMillis() var pollingInterval = minPollIntervalMs var retryCounter = 0 val printedFlows = mutableSetOf() do { val upload: UploadStatus = try { client.uploadStatus(authToken, uploadId, projectId) } catch (e: ApiClient.ApiException) { if (e.statusCode == 429) { // back off through extending sleep duration with 25% pollingInterval = (pollingInterval * 1.25).toLong() Thread.sleep(pollingInterval) continue } if (e.statusCode == 500 || e.statusCode == 502 || e.statusCode == 404) { if (++retryCounter <= maxPollingRetries) { // retry on 500 Thread.sleep(pollingInterval) continue } } throw CliError("Failed to fetch the status of an upload $uploadId. Status code = ${e.statusCode}") } for (uploadFlowResult in upload.flows) { if(printedFlows.contains(uploadFlowResult)) { continue } if(!terminalStatuses.contains(uploadFlowResult.status)) { continue } printedFlows.add(uploadFlowResult); TestSuiteStatusView.showFlowCompletion( uploadFlowResult.toViewModel() ) } if (upload.completed) { val runningFlows = RunningFlows( flows = upload.flows.map { flowResult -> RunningFlow( flowResult.name, flowResult.status, duration = flowResult.totalTime?.milliseconds, startTime = flowResult.startTime ) }, duration = upload.totalTime?.milliseconds, startTime = upload.startTime ) return handleSyncUploadCompletion( upload = upload, runningFlows = runningFlows, appId = appId, failOnCancellation = failOnCancellation, reportFormat = reportFormat, reportOutput = reportOutput, testSuiteName = testSuiteName, uploadUrl = uploadUrl ) } Thread.sleep(pollingInterval) } while (System.currentTimeMillis() - startTime < waitTimeoutMs) 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$uploadUrl") if (failOnTimeout) { PrintUtils.message("Process will exit with code 1 (FAIL)") PrintUtils.message("* To change exit code on Timeout, run maestro with this option: `maestro cloud --fail-on-timeout=`") } else { PrintUtils.message("Process will exit with code 0 (SUCCESS)") PrintUtils.message("* To change exit code on Timeout, run maestro with this option: `maestro cloud --fail-on-timeout=`") } // Fetch the latest upload status before returning return try { client.uploadStatus(authToken, uploadId, projectId) } catch (e: Exception) { // If we can't fetch the latest status, return a timeout status UploadStatus( uploadId = uploadId, status = UploadStatus.Status.ERROR, completed = false, totalTime = null, startTime = null, flows = emptyList(), appPackageId = null, wasAppLaunched = false, ) } } private fun handleSyncUploadCompletion( upload: UploadStatus, runningFlows: RunningFlows, appId: String, failOnCancellation: Boolean, reportFormat: ReportFormat, reportOutput: File?, testSuiteName: String?, uploadUrl: String, ): UploadStatus { TestSuiteStatusView.showSuiteResult( upload.toViewModel( TestSuiteStatusView.TestSuiteViewModel.UploadDetails( uploadId = upload.uploadId, appId = appId, domain = client.domain, ) ), uploadUrl ) val isCancelled = upload.status == UploadStatus.Status.CANCELED val isFailure = upload.status == UploadStatus.Status.ERROR val containsFailure = upload.flows.find { it.status == FlowStatus.ERROR } != null // status can be cancelled but also contain flow with failure val failed = isFailure || containsFailure || isCancelled && failOnCancellation val reportOutputSink = reportFormat.fileExtension ?.let { extension -> (reportOutput ?: File("report$extension")) .sink() .buffer() } if (reportOutputSink != null) { saveReport( reportFormat, !failed, createSuiteResult(!failed, upload, runningFlows), reportOutputSink, testSuiteName ) } if (!failed) { PrintUtils.message("Process will exit with code 0 (SUCCESS)") if (isCancelled) { PrintUtils.message("* To change exit code on Cancellation, run maestro with this option: `maestro cloud --fail-on-cancellation=`") } } else { PrintUtils.message("Process will exit with code 1 (FAIL)") if (isCancelled && !containsFailure) { PrintUtils.message("* To change exit code on cancellation, run maestro with this option: `maestro cloud --fail-on-cancellation=`") } } return upload } private fun saveReport( reportFormat: ReportFormat, passed: Boolean, suiteResult: TestExecutionSummary.SuiteResult, reportOutputSink: BufferedSink, testSuiteName: String? ) { ReporterFactory.buildReporter(reportFormat, testSuiteName) .report( TestExecutionSummary( passed = passed, suites = listOf(suiteResult) ), reportOutputSink, ) } private fun createSuiteResult( passed: Boolean, upload: UploadStatus, runningFlows: RunningFlows ): TestExecutionSummary.SuiteResult { return TestExecutionSummary.SuiteResult( passed = passed, flows = upload.flows.map { uploadFlowResult -> val failure = uploadFlowResult.errors.firstOrNull() val currentRunningFlow = runningFlows.flows.find { it.name == uploadFlowResult.name } TestExecutionSummary.FlowResult( name = uploadFlowResult.name, fileName = null, status = uploadFlowResult.status, failure = if (failure != null) TestExecutionSummary.Failure(failure) else null, duration = currentRunningFlow?.duration, startTime = currentRunningFlow?.startTime ) }, duration = runningFlows.duration, startTime = runningFlows.startTime ) } fun analyze( apiKey: String?, debugFiles: AnalysisDebugFiles, debugOutputPath: Path, ): Int { val authToken = auth.getAuthToken(apiKey) if (authToken == null) throw CliError("Failed to get authentication token") PrintUtils.info("\n\uD83D\uDD0E Analyzing Flow(s)...") try { val response = client.analyze(authToken, debugFiles) if (response.htmlReport.isNullOrEmpty()) { PrintUtils.info(response.output) return 0 } val outputFilePath = HtmlInsightsAnalysisReporter().report(response.htmlReport, debugOutputPath) val os = System.getProperty("os.name").lowercase(Locale.getDefault()) val formattedOutput = response.output.replace( "{{outputFilePath}}", "file:${if (os.contains("win")) "///" else "//"}${outputFilePath}\n" ) PrintUtils.info(formattedOutput); return 0; } catch (error: CliError) { PrintUtils.err("Unexpected error while analyzing Flow(s): ${error.message}") return 1 } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/BugReportCommand.kt ================================================ package maestro.cli.command import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.debuglog.DebugLogStore import picocli.CommandLine import java.util.concurrent.Callable @CommandLine.Command( name = "bugreport", description = [ "Report a bug - Help us improve your experience!" ] ) class BugReportCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null override fun call(): Int { val message = """ Please open an issue on GitHub: https://github.com/mobile-dev-inc/Maestro/issues/new?template=bug_report.yaml Attach the files found in this folder ${DebugLogStore.logDirectory} """.trimIndent() println(message) return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/ChatCommand.kt ================================================ package maestro.cli.command import maestro.auth.ApiKey import maestro.cli.api.ApiClient import maestro.cli.auth.Auth import maestro.cli.util.EnvUtils.BASE_API_URL import org.fusesource.jansi.Ansi.ansi import picocli.CommandLine import java.util.* import java.util.concurrent.Callable @CommandLine.Command( name = "chat", description = [ "Use Maestro GPT to help you with Maestro documentation and code questions" ] ) class ChatCommand : Callable { @CommandLine.Option(order = 0, names = ["--api-key", "--apiKey"], description = ["API key"]) private var apiKey: String? = null @CommandLine.Option(order = 1, names = ["--api-url", "--apiUrl"], description = ["API base URL"]) private var apiUrl: String = BASE_API_URL @CommandLine.Option( order = 2, names = ["--ask"], description = ["Gets a response and immediately exits the chat session"] ) private var ask: String? = null private val auth by lazy { Auth(ApiClient(apiUrl)) } override fun call(): Int { if (apiKey == null) { apiKey = ApiKey.getToken() } if (apiKey == null) { println("You must log in first in to use this command (maestro login).") return 1 } val client = ApiClient(apiUrl) if (ask == null) { println( """ Welcome to MaestroGPT! You can ask questions about Maestro documentation and code. To exit, type "quit" or "exit". """.trimIndent() ) } val sessionId = "maestro_cli:" + UUID.randomUUID().toString() while (true) { if(ask == null) { print(ansi().fgBrightMagenta().a("> ").reset().toString()) } val question = ask ?: readLine() if (question == null || question == "quit" || question == "exit") { println("Goodbye!") return 0 } val messages = client.botMessage(question, sessionId, apiKey!!) println() messages.filter { it.role == "assistant" }.mapNotNull { message -> message.content.map { it.text }.joinToString("\n").takeIf { it.isNotBlank() } }.forEach { message -> if(ask != null) { println(message) } else { println( ansi().fgBrightMagenta().a("MaestroGPT> ").reset().fgBrightCyan().a(message).reset().toString() ) } println() } if (ask != null) { return 0 } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/CheckSyntaxCommand.kt ================================================ package maestro.cli.command import maestro.cli.CliError import maestro.orchestra.error.SyntaxError import maestro.orchestra.yaml.YamlCommandReader import picocli.CommandLine import java.io.File import java.util.concurrent.Callable @CommandLine.Command( name = "check-syntax", description = [ "Check syntax of Maestro code" ] ) class CheckSyntaxCommand : Callable { @CommandLine.Parameters( index = "0", description = ["Check syntax of Maestro flow file or \"-\" for stdin"], ) private lateinit var file: File override fun call(): Int { val maestroCode = if (file.path == "-") { System.`in`.readBytes().toString(Charsets.UTF_8) } else { if (!file.exists()) throw CliError("File does not exist: ${file.absolutePath}") file.readText() } if (maestroCode.isBlank()) throw CliError("Maestro code is empty.") try { YamlCommandReader.checkSyntax(maestroCode) println("OK") } catch (e: SyntaxError) { throw CliError(e.message) } return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.command import maestro.cli.App import maestro.cli.CliError import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.api.ApiClient import maestro.cli.cloud.CloudInteractor import maestro.cli.report.ReportFormat import maestro.orchestra.validation.AppMetadataAnalyzer import maestro.cli.web.WebInteractor 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 import picocli.CommandLine import picocli.CommandLine.Option import java.io.File import java.util.concurrent.Callable import java.util.concurrent.TimeUnit import maestro.orchestra.util.Env.withDefaultEnvVars @CommandLine.Command( name = "cloud", description = [ "Upload your flows on Cloud by using @|yellow `maestro cloud sample/app.apk flows_folder/`|@ (@|cyan https://app.maestro.dev|@)", "Provide your application file and a folder with Maestro flows to run them in parallel on multiple devices in the cloud", "By default, the command will block until all analyses have completed. You can use the --async flag to run the command asynchronously and exit immediately.", ] ) class CloudCommand : Callable { @CommandLine.Spec var spec: CommandLine.Model.CommandSpec? = null @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.Parameters(hidden = true, arity = "0..2", description = ["App file and/or Flow file i.e "]) private lateinit var files: List @Option(names = ["--config"], description = ["Optional .yaml configuration file for Flows. If not provided, Maestro will look for a config.yaml file in the root directory."]) private var configFile: File? = null @Option(names = ["--app-file"], description = ["App binary to run your Flows against"]) private var appFile: File? = null @Option(order = 1, names = ["--flows"], description = ["A Flow filepath or a folder path that contains Flows"]) private lateinit var flowsFile: File @Option(order = 0, names = ["--api-key", "--apiKey"], description = ["API key"]) private var apiKey: String? = null @Option(order = 1, names = ["--project-id", "--projectId"], description = ["Project Id"]) private var projectId: String? = null @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 = 4, names = ["--repo-owner", "--repoOwner"], description = ["Repository owner (ie: GitHub organization or user slug)"]) private var repoOwner: String? = null @Option(order = 5, names = ["--repo-name", "--repoName"], description = ["Repository name (ie: GitHub repo slug)"]) private var repoName: String? = null @Option(order = 6, names = ["--branch"], description = ["The branch this upload originated from"]) private var branch: String? = null @Option(order = 7, names = ["--commit-sha", "--commitSha"], description = ["The commit SHA of this upload"]) private var commitSha: String? = null @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 = 9, names = ["-e", "--env"], description = ["Environment variables to inject into your Flows"]) private var env: Map = emptyMap() @Option(order = 10, names = ["--name"], description = ["Name of the upload"]) private var uploadName: String? = null @Option(order = 11, names = ["--async"], description = ["Run the upload asynchronously"]) private var async: Boolean = false @Deprecated("Use --device-os instead") @Option(order = 12, hidden = true, names = ["--android-api-level"], description = ["Android API level to run your flow against"]) private var androidApiLevel: Int? = null @Option( order = 13, names = ["--include-tags"], description = ["List of tags that will remove the Flows that does not have the provided tags"], split = ",", ) private var includeTags: List = emptyList() @Option( order = 14, names = ["--exclude-tags"], description = ["List of tags that will remove the Flows containing the provided tags"], split = ",", ) private var excludeTags: List = emptyList() @Option( order = 15, names = ["--format"], description = ["Test report format (default=\${DEFAULT-VALUE}): \${COMPLETION-CANDIDATES}"], ) private var format: ReportFormat = ReportFormat.NOOP @Option( names = ["--test-suite-name"], description = ["Test suite name"], ) private var testSuiteName: String? = null @Option( order = 16, names = ["--output"], description = ["File to write report into (default=report.xml)"], ) private var output: File? = null @Deprecated("Use --device-os instead") @Option(order = 17, hidden = true, names = ["--ios-version"], description = ["iOS version to run your flow against. Please use --device-os instead"]) private var iOSVersion: String? = null @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 = 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(order = 20, names = ["--device-model"], description = [ "Device model to run your flow against. " + "iOS: iPhone-11, iPhone-11-Pro, etc. Run command: maestro list-cloud-devices" + "Android: pixel_6, etc. Run command: maestro list-cloud-devices" ]) private var deviceModel: String? = null @Option(order = 21, names = ["--device-os"], description = [ "OS version to run your flow against. " + "iOS: iOS-16-2, iOS-17-5, iOS-18-2, etc. maestro list-devices" + "Android: android-33, android-34, etc. maestro list-cloud-devices" ]) private var deviceOs: String? = null @Option(hidden = true, names = ["--fail-on-cancellation"], description = ["Fail the command if the upload is marked as cancelled"]) private var failOnCancellation: Boolean = false @Option(hidden = true, names = ["--fail-on-timeout"], description = ["Fail the command if the upload times outs"]) private var failOnTimeout: Boolean = true @Option(hidden = true, names = ["--disable-notifications"], description = ["Do not send the notifications configured in config.yaml"]) private var disableNotifications = false @Option(hidden = true, names = ["--timeout"], description = ["Minutes to wait until all flows complete"]) private var resultWaitTimeout = 60 @CommandLine.ParentCommand private val parent: App? = null override fun call(): Int { TestDebugReporter.install( debugOutputPathAsString = null, flattenDebugOutput = false, printToConsole = parent?.verbose == true, ) validateFiles() validateWorkSpace() // Upload val apiUrl = apiUrl ?: "https://api.copilot.mobile.dev" env = env .withInjectedShellEnvVars() .withDefaultEnvVars(flowsFile) val apiClient = ApiClient(apiUrl) val webManifestProvider = if (flowsFile.isWebFlow()) { { WebInteractor.createManifestFromWorkspace(flowsFile) } } else null return CloudInteractor( client = apiClient, appFileValidator = { AppMetadataAnalyzer.validateAppFile(it) }, workspaceValidator = maestro.orchestra.validation.WorkspaceValidator(), webManifestProvider = webManifestProvider, failOnTimeout = failOnTimeout, waitTimeoutMs = TimeUnit.MINUTES.toMillis(resultWaitTimeout.toLong()) ).upload( async = async, flowFile = flowsFile, appFile = appFile, mapping = mapping, env = env, uploadName = uploadName, repoOwner = repoOwner, repoName = repoName, branch = branch, commitSha = commitSha, pullRequestId = pullRequestId, apiKey = apiKey, appBinaryId = appBinaryId, includeTags = includeTags, excludeTags = excludeTags, reportFormat = format, reportOutput = output, failOnCancellation = failOnCancellation, testSuiteName = testSuiteName, disableNotifications = disableNotifications, deviceLocale = deviceLocale, projectId = projectId, deviceModel = deviceModel, deviceOs = deviceOs, androidApiLevel = androidApiLevel, iOSVersion = iOSVersion ) } private fun validateWorkSpace() { try { PrintUtils.message("Evaluating flow(s)...") WorkspaceExecutionPlanner .plan( input = setOf(flowsFile.toPath().toAbsolutePath()), includeTags = includeTags, excludeTags = excludeTags, config = configFile?.toPath()?.toAbsolutePath(), ) } catch (e: Exception) { throw CliError("Upload aborted. Received error when evaluating flow(s):\n\n${e.message}") } } private fun validateFiles() { if (configFile != null && configFile?.exists()?.not() == true) { throw CliError("The config file ${configFile?.absolutePath} does not exist.") } // Maintains backwards compatibility for this syntax: maestro cloud // App file can be optional now if (this::files.isInitialized) { when (files.size) { 2 -> { appFile = files[0] flowsFile = files[1] } 1 -> { flowsFile = files[0] } } } 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', " + "'--flows'. " + "Example:" + " maestro cloud --app-file --flows ") } if (!hasApp) throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption("--app-file"), "Missing required parameter for option '--app-file' or " + "'--app-binary-id'") if (!hasWorkspace) throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption("--flows"), "Missing required parameter for option '--flows'") } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/DownloadSamplesCommand.kt ================================================ package maestro.cli.command import kotlinx.coroutines.runBlocking import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.util.FileDownloader import maestro.cli.util.PrintUtils.err import maestro.cli.util.PrintUtils.message import maestro.cli.view.ProgressBar import org.rauschig.jarchivelib.ArchiverFactory import picocli.CommandLine import picocli.CommandLine.Option import java.io.File import java.util.concurrent.Callable @CommandLine.Command( name = "download-samples", description = [ "Download sample apps and flows for trying out maestro without setting up your own app" ] ) class DownloadSamplesCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @Option(names = ["-o", "--output"], description = ["Output directory"]) private var outputDirectory: File? = null override fun call(): Int { val folder = ensureSamplesFolder() val samplesFile = File("maestro-samples.zip") return runBlocking { try { downloadSamplesZip(samplesFile) val archiver = ArchiverFactory.createArchiver(samplesFile) archiver.extract(samplesFile, folder) message("✅ Samples downloaded to $folder/") return@runBlocking 0 } catch (e: Exception) { err(e.message ?: "Error downloading samples: $e") return@runBlocking 1 } finally { samplesFile.delete() } } } private suspend fun downloadSamplesZip(file: File) { val progressView = ProgressBar(20) FileDownloader .downloadFile( SAMPLES_URL, file ).collect { when (it) { is FileDownloader.DownloadResult.Success -> { // Do nothing } is FileDownloader.DownloadResult.Error -> { throw it.cause ?: error(it.message) } is FileDownloader.DownloadResult.Progress -> { progressView.set(it.progress) } } } } private fun ensureSamplesFolder(): File { val outputDir = outputDirectory ?: File("samples") if (!outputDir.exists()) { outputDir.mkdirs() } return outputDir } companion object { private const val SAMPLES_URL = "https://storage.googleapis.com/mobile.dev/samples/samples.zip" } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/DriverCommand.kt ================================================ package maestro.cli.command import maestro.cli.driver.DriverBuilder import maestro.cli.driver.RealIOSDeviceDriver import picocli.CommandLine import java.util.concurrent.Callable @CommandLine.Command( name = "driver-setup", description = [ "Setup maestro drivers on your devices. Right now works for real iOS devices" ], hidden = true ) class DriverCommand : Callable { @CommandLine.Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."], hidden = true ) private var appleTeamId: String? = null @CommandLine.Option( names = ["--destination"], description = ["Destination device to build the driver for. Defaults to generic/platform=iphoneos if not specified."], hidden = true ) private var destination: String? = null override fun call(): Int { val teamId = requireNotNull(appleTeamId) { "Apple account team ID must be specified." } val destination = destination ?: "generic/platform=iphoneos" val driverBuilder = DriverBuilder() RealIOSDeviceDriver( teamId = teamId, destination = destination, driverBuilder = driverBuilder, ).validateAndUpdateDriver(force = true) return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/ListCloudDevicesCommand.kt ================================================ package maestro.cli.command import maestro.cli.App import maestro.cli.CliError import maestro.cli.ShowHelpMixin import maestro.cli.api.ApiClient import maestro.cli.report.TestDebugReporter import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils import maestro.cli.view.bold import maestro.cli.view.cyan import maestro.device.Platform import picocli.CommandLine import java.util.concurrent.Callable @CommandLine.Command( name = "list-cloud-devices", description = ["List devices available on Maestro Cloud, grouped by platform"], ) class ListCloudDevicesCommand : Callable { @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Option( names = ["--platform"], description = ["Filter by platform: android, ios, web"], ) private var platform: String? = null override fun call(): Int { TestDebugReporter.install(null, printToConsole = parent?.verbose == true) val platformFilter = platform?.let { input -> Platform.fromString(input) } val apiClient = ApiClient(EnvUtils.BASE_API_URL) println() PrintUtils.info("Cloud Devices", bold = true) println("─".repeat(SEPARATOR_WIDTH)) val cloudDevices = try { apiClient.listCloudDevices() } catch (e: ApiClient.ApiException) { if (e.statusCode == null) PrintUtils.err("Unable to reach Maestro Cloud. Please check your network connection and try again.") throw e } val platformOrder = listOf(Platform.IOS, Platform.ANDROID, Platform.WEB) val platforms = if (platformFilter != null) listOf(platformFilter) else platformOrder val sections = platforms.mapNotNull { p -> val key = p.name.lowercase() val raw = cloudDevices[key] ?: return@mapNotNull null val groups = raw.map { (model, osList) -> DeviceGroup(model, osList) } p to groups }.filter { it.second.isNotEmpty() } if (sections.isEmpty()) { println("No cloud devices found") return 0 } sections.forEachIndexed { idx, (p, groups) -> if (idx > 0) println() printSection(p, groups) } return 0 } private data class DeviceGroup( val model: String, val osList: List, ) private fun printSection(platform: Platform, groups: List) { println(platform.description.bold()) val modelW = groups.maxOf { it.model.length } for (g in groups) { val osLine = g.osList.joinToString(", ") println(row(g.model.cyan().padEnd(modelW + ansiExtra(g.model.cyan())), osLine)) } } private fun row(vararg cols: String) = " " + cols.joinToString(" ") private fun ansiExtra(s: String) = s.length - s.replace(ANSI_RE, "").length companion object { private val ANSI_RE = Regex("\u001B\\[[\\d;]*[^\\d;]") private const val SEPARATOR_WIDTH = 53 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/ListDevicesCommand.kt ================================================ package maestro.cli.command import maestro.cli.App import maestro.cli.CliError import maestro.cli.ShowHelpMixin import maestro.cli.report.TestDebugReporter import maestro.cli.util.PrintUtils import maestro.cli.view.bold import maestro.cli.view.cyan import maestro.cli.view.faint import maestro.device.Device import maestro.device.DeviceService import maestro.device.Platform import picocli.CommandLine import java.util.concurrent.Callable @CommandLine.Command( name = "list-devices", description = ["List local devices available, grouped by platform"], ) class ListDevicesCommand : Callable { @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Option( names = ["--platform"], description = ["Filter by platform: android, ios, web"], ) private var platform: String? = null override fun call(): Int { TestDebugReporter.install(null, printToConsole = parent?.verbose == true) val platformFilter = platform?.let { input -> Platform.fromString(input) } println("Showing local devices. Use 'maestro list-cloud-device' to list devices available on Maestro Cloud.".faint()) println() PrintUtils.info("Local Devices", bold = true) println("─".repeat(SEPARATOR_WIDTH)) val devices = DeviceService.listDevices(includeWeb = true) val platforms = if (platformFilter != null) listOf(platformFilter) else Platform.entries val sections = platforms.map { p -> p to devices.filter { it.platform == p }.groupedByModel() } .filter { it.second.isNotEmpty() } if (sections.isEmpty()) { println("No devices found") return 0 } sections.forEachIndexed { idx, (p, groups) -> if (idx > 0) println() printSection(p, groups) } return 0 } private data class DeviceGroup( val model: String, val osList: List, ) private fun List.groupedByModel(): List { val groups = LinkedHashMap>() for (device in this) { if (device.deviceSpec.model.isEmpty()) continue val osList = groups.getOrPut(device.deviceSpec.model) { mutableListOf() } if (device.deviceSpec.os.isNotEmpty() && device.deviceSpec.os !in osList) { osList.add(device.deviceSpec.os) } } return groups.map { (model, osList) -> DeviceGroup(model, osList) } } private fun printSection(platform: Platform, groups: List) { println(platform.description.bold()) val modelW = groups.maxOf { it.model.length } for (g in groups) { val osLine = g.osList.joinToString(", ") println(row(g.model.cyan().padEnd(modelW + ansiExtra(g.model.cyan())), osLine)) } } private fun row(vararg cols: String) = " " + cols.joinToString(" ") private fun ansiExtra(s: String) = s.length - s.replace(ANSI_RE, "").length companion object { private val ANSI_RE = Regex("\u001B\\[[\\d;]*[^\\d;]") private const val SEPARATOR_WIDTH = 53 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/LoginCommand.kt ================================================ package maestro.cli.command import maestro.auth.ApiKey import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.analytics.Analytics import maestro.cli.analytics.UserLoggedOutEvent import maestro.cli.api.ApiClient import maestro.cli.auth.Auth import maestro.cli.util.PrintUtils.message import picocli.CommandLine import java.util.concurrent.Callable import kotlin.io.path.absolutePathString import maestro.cli.report.TestDebugReporter import maestro.debuglog.LogConfig import picocli.CommandLine.Option @CommandLine.Command( name = "login", description = [ "Log into Maestro Cloud" ] ) class LoginCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @Option(names = ["--api-url", "--apiUrl"], description = ["API base URL"]) private var apiUrl: String = "https://api.copilot.mobile.dev" private val auth by lazy { Auth(ApiClient(apiUrl)) } override fun call(): Int { Analytics.trackEvent(UserLoggedOutEvent()) LogConfig.configure(logFileName = null, printToConsole = false) // Disable all logs from Login val token = auth.triggerSignInFlow() println(token) return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/LogoutCommand.kt ================================================ package maestro.cli.command import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.analytics.Analytics import maestro.cli.analytics.UserLoggedOutEvent import org.fusesource.jansi.Ansi import picocli.CommandLine import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.Callable import kotlin.io.path.deleteIfExists import maestro.cli.util.PrintUtils import maestro.cli.util.PrintUtils.message @CommandLine.Command( name = "logout", description = [ "Log out of Maestro Cloud" ] ) class LogoutCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null private val cachedAuthTokenFile: Path = Paths.get(System.getProperty("user.home"), ".mobiledev", "authtoken") override fun call(): Int { // Track logout event before deleting the token Analytics.trackEvent(UserLoggedOutEvent()) cachedAuthTokenFile.deleteIfExists() message("Logged out.") return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/McpCommand.kt ================================================ package maestro.cli.command import picocli.CommandLine import java.util.concurrent.Callable import maestro.cli.mcp.runMaestroMcpServer import java.io.File import maestro.cli.util.WorkingDirectory @CommandLine.Command( name = "mcp", description = [ "Starts the Maestro MCP server, exposing Maestro device and automation commands as Model Context Protocol (MCP) tools over STDIO for LLM agents and automation clients." ], ) class McpCommand : Callable { @CommandLine.Option( names = ["--working-dir"], description = ["Base working directory for resolving files"] ) private var workingDir: File? = null override fun call(): Int { if (workingDir != null) { WorkingDirectory.baseDir = workingDir!!.absoluteFile } runMaestroMcpServer() return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/PrintHierarchyCommand.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.command import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import maestro.TreeNode import maestro.cli.App import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.analytics.Analytics import maestro.cli.analytics.PrintHierarchyFinishedEvent import maestro.cli.analytics.PrintHierarchyStartedEvent import maestro.cli.report.TestDebugReporter import maestro.cli.session.MaestroSessionManager import maestro.cli.view.yellow import maestro.utils.CliInsights import maestro.utils.Insight import maestro.utils.chunkStringByWordCount import picocli.CommandLine import picocli.CommandLine.Option import java.lang.StringBuilder @CommandLine.Command( name = "hierarchy", description = [ "Print out the view hierarchy of the connected device" ] ) class PrintHierarchyCommand : Runnable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Option( names = ["--android-webview-hierarchy"], description = ["Set to \"devtools\" to use Chrome dev tools for Android WebView hierarchy"], hidden = true, ) private var androidWebViewHierarchy: String? = null @CommandLine.Option( names = ["--reinstall-driver"], description = ["Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation."], negatable = true, defaultValue = "true", fallbackValue = "true" ) private var reinstallDriver: Boolean = true @Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."], hidden = true ) private var appleTeamId: String? = null @CommandLine.Option( names = ["--compact"], description = ["Output in CSV format with element_num,depth,attributes,parent_num columns"], hidden = false ) private var compact: Boolean = false @CommandLine.Option( names = ["--device-index"], description = ["The index of the device to run the test on"], hidden = true ) private var deviceIndex: Int? = null override fun run() { TestDebugReporter.install( debugOutputPathAsString = null, flattenDebugOutput = false, printToConsole = parent?.verbose == true, ) // Track print hierarchy start val platform = parent?.platform ?: "unknown" val startTime = System.currentTimeMillis() Analytics.trackEvent(PrintHierarchyStartedEvent(platform = platform)) MaestroSessionManager.newSession( host = parent?.host, port = parent?.port, driverHostPort = null, teamId = appleTeamId, deviceId = parent?.deviceId, platform = parent?.platform, reinstallDriver = reinstallDriver, deviceIndex = deviceIndex ) { session -> session.maestro.setAndroidChromeDevToolsEnabled(androidWebViewHierarchy == "devtools") val callback: (Insight) -> Unit = { val message = StringBuilder() val level = it.level.toString().lowercase().replaceFirstChar(Char::uppercase) message.append(level.yellow() + ": ") it.message.chunkStringByWordCount(12).forEach { chunkedMessage -> message.append("$chunkedMessage ") } println(message.toString()) } val insights = CliInsights insights.onInsightsUpdated(callback) val tree = session.maestro.viewHierarchy().root insights.unregisterListener(callback) if (compact) { // Output in CSV format println("element_num,depth,attributes,parent_num") val nodeToId = mutableMapOf() val csv = StringBuilder() // Assign IDs to each node var counter = 0 tree?.aggregate()?.forEach { node -> nodeToId[node] = counter++ } // Process tree recursively to generate CSV processTreeToCSV(tree, 0, null, nodeToId, csv) println(csv.toString()) } else { // Original JSON output format val hierarchy = jacksonObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL) .writerWithDefaultPrettyPrinter() .writeValueAsString(tree) println(hierarchy) } } // Track successful completion val duration = System.currentTimeMillis() - startTime Analytics.trackEvent(PrintHierarchyFinishedEvent( platform = platform, success = true, durationMs = duration )) Analytics.flush() } private fun processTreeToCSV( node: TreeNode?, depth: Int, parentId: Int?, nodeToId: Map, csv: StringBuilder ) { if (node == null) return val nodeId = nodeToId[node] ?: return // Build attributes string val attributesList = mutableListOf() // Add normal attributes node.attributes.forEach { (key, value) -> if (value.isNotEmpty() && value != "false") { attributesList.add("$key=$value") } } // Add boolean properties if true if (node.clickable == true) attributesList.add("clickable=true") if (node.enabled == true) attributesList.add("enabled=true") if (node.focused == true) attributesList.add("focused=true") if (node.checked == true) attributesList.add("checked=true") if (node.selected == true) attributesList.add("selected=true") // Join all attributes with "; " val attributesString = attributesList.joinToString("; ") // Escape quotes in the attributes string if needed val escapedAttributes = attributesString.replace("\"", "\"\"") // Add this node to CSV csv.append("$nodeId,$depth,\"$escapedAttributes\",${parentId ?: ""}\n") // Process children node.children.forEach { child -> processTreeToCSV(child, depth + 1, nodeId, nodeToId, csv) } } private fun removeEmptyValues(tree: TreeNode?): TreeNode? { if (tree == null) { return null } return TreeNode( attributes = tree.attributes.filter { it.value != "" && it.value.toString() != "false" }.toMutableMap(), children = tree.children.map { removeEmptyValues(it) }.filterNotNull(), checked = if(tree.checked == true) true else null, clickable = if(tree.clickable == true) true else null, enabled = if(tree.enabled == true) true else null, focused = if(tree.focused == true) true else null, selected = if(tree.selected == true) true else null, ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/QueryCommand.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.command import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import maestro.ElementFilter import maestro.Filters import maestro.cli.App import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.session.MaestroSessionManager import maestro.cli.view.red import maestro.orchestra.Orchestra import maestro.utils.StringUtils.toRegexSafe import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Model import picocli.CommandLine.Option import picocli.CommandLine.Spec @Command( name = "query", description = [ "Find elements in the view hierarchy of the connected device" ], hidden = true ) class QueryCommand : Runnable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @Option(names = ["text"]) private var text: String? = null @Option(names = ["id"]) private var id: String? = null @Spec lateinit var commandSpec: Model.CommandSpec @Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."], hidden = true ) private var appleTeamId: String? = null override fun run() { MaestroSessionManager.newSession( host = parent?.host, port = parent?.port, driverHostPort = null, deviceId = parent?.deviceId, platform = parent?.platform, teamId = appleTeamId, ) { session -> val filters = mutableListOf() text?.let { filters += Filters.textMatches(it.toRegexSafe(Orchestra.REGEX_OPTIONS)) } id?.let { filters += Filters.idMatches(it.toRegexSafe(Orchestra.REGEX_OPTIONS)) } if (filters.isEmpty()) { throw CommandLine.ParameterException( commandSpec.commandLine(), "Must specify at least one search criteria" ) } val elements = session.maestro.allElementsMatching( Filters.intersect(filters) ) val mapper = jacksonObjectMapper() .writerWithDefaultPrettyPrinter() println("Matches: ${elements.size}") elements.forEach { println( mapper.writeValueAsString(it) ) } } System.err.println("This command is deprecated. Use \"maestro studio\" instead.".red()) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.command import maestro.cli.App import maestro.cli.CliError import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.analytics.Analytics import maestro.cli.analytics.RecordFinishedEvent import maestro.cli.analytics.RecordStartedEvent import maestro.cli.graphics.LocalVideoRenderer import maestro.cli.graphics.RemoteVideoRenderer import maestro.cli.graphics.SkiaFrameRenderer import maestro.cli.report.TestDebugReporter import maestro.cli.runner.TestRunner import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.session.MaestroSessionManager import maestro.cli.util.FileUtils.isWebFlow import maestro.orchestra.workspace.WorkspaceExecutionPlanner import okio.sink import picocli.CommandLine import picocli.CommandLine.Option import java.io.File import java.util.concurrent.Callable @CommandLine.Command( name = "record", description = [ "Render a beautiful video of your Flow - Great for demos and bug reports" ] ) class RecordCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Parameters(index = "0", description = ["The Flow file to record."]) private lateinit var flowFile: File @CommandLine.Parameters(description = ["Output file for the rendered video. Only valid for local rendering (--local)."], arity = "0..1", index = "1") private var outputFile: File? = null @Option(names = ["--config"], description = ["Optional .yaml configuration file for Flows. If not provided, Maestro will look for a config.yaml file in the root directory."]) private var configFile: File? = null @Option(names = ["--local"], description = ["(Beta) Record using local rendering. This will become the default in a future Maestro release."]) private var local: Boolean = false @Option(names = ["-e", "--env"]) private var env: Map = emptyMap() @Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."] ) private var appleTeamId: String? = null @CommandLine.Spec lateinit var commandSpec: CommandLine.Model.CommandSpec @Option( names = ["--debug-output"], description = ["Configures the debug output in this path, instead of default"] ) private var debugOutput: String? = null override fun call(): Int { // Track record start val startTime = System.currentTimeMillis() val platform = parent?.platform ?: "unknown" Analytics.trackEvent(RecordStartedEvent(platform = platform)) if (!flowFile.exists()) { throw CommandLine.ParameterException( commandSpec.commandLine(), "File not found: $flowFile" ) } if (!local && outputFile != null) { throw CommandLine.ParameterException( commandSpec.commandLine(), "The outputFile parameter is only valid for local rendering (--local).", ) } if (configFile != null && configFile?.exists()?.not() == true) { throw CliError("The config file ${configFile?.absolutePath} does not exist.") } TestDebugReporter.install(debugOutputPathAsString = debugOutput, printToConsole = parent?.verbose == true) val path = TestDebugReporter.getDebugOutputPath() val deviceId = if (flowFile.isWebFlow()) { throw CliError("'record' command does not support web flows yet.") } else { parent?.deviceId } val plan = WorkspaceExecutionPlanner.plan( input = setOf(flowFile.toPath()), includeTags = emptyList(), excludeTags = emptyList(), config = configFile?.toPath() ) return MaestroSessionManager.newSession( host = parent?.host, port = parent?.port, driverHostPort = null, deviceId = deviceId, teamId = appleTeamId, platform = parent?.platform, executionPlan = plan, block = { session -> val maestro = session.maestro val device = session.device if (flowFile.isDirectory) { throw CommandLine.ParameterException( commandSpec.commandLine(), "Only single Flows are supported by \"maestro record\". $flowFile is a directory.", ) } val resultView = AnsiResultView() val screenRecording = kotlin.io.path.createTempFile(suffix = ".mp4").toFile() val exitCode = screenRecording.sink().use { out -> maestro.startScreenRecording(out).use { TestRunner.runSingle( maestro, device, flowFile, env, resultView, path, testOutputDir = null, deviceId = parent?.deviceId, ) } } val frames = resultView.getFrames() val localOutputFile = outputFile ?: path.resolve("maestro-recording.mp4").toFile() val videoRenderer = if (local) LocalVideoRenderer( frameRenderer = SkiaFrameRenderer(), outputFile = localOutputFile, outputFPS = 25, outputWidthPx = 1920, outputHeightPx = 1080, ) else RemoteVideoRenderer() videoRenderer.render(screenRecording, frames) TestDebugReporter.deleteOldFiles() // Track record completion val duration = System.currentTimeMillis() - startTime Analytics.trackEvent(RecordFinishedEvent(platform = platform, durationMs = duration)) Analytics.flush() exitCode }, ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/StartDeviceCommand.kt ================================================ package maestro.cli.command import maestro.cli.App import maestro.cli.CliError import maestro.cli.ShowHelpMixin import maestro.cli.device.DeviceCreateUtil import maestro.device.DeviceService import maestro.cli.report.TestDebugReporter import maestro.cli.util.EnvUtils import maestro.device.DeviceSpec import maestro.device.DeviceSpecRequest import maestro.device.Platform import picocli.CommandLine import java.util.concurrent.Callable @CommandLine.Command( name = "start-device", description = [ "Starts or creates an iOS Simulator or Android Emulator similar to the ones on the cloud", "Supported device types: iPhone11 (iOS), Pixel 6 (Android)", ] ) class StartDeviceCommand : Callable { @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Option( order = 0, names = ["--platform"], required = true, description = ["Platforms: android, ios, web"], ) private lateinit var platform: String @Deprecated("Use --device-os instead") @CommandLine.Option( order = 1, hidden = true, names = ["--os-version"], description = ["OS version to use:", "iOS: 16, 17, 18", "Android: 28, 29, 30, 31, 33"], ) private var osVersion: String? = null @CommandLine.Option( order = 2, names = ["--device-locale"], description = ["a combination of lowercase ISO-639-1 code and uppercase ISO-3166-1 code i.e. \"de_DE\" for Germany"], ) private var deviceLocale: String? = null @CommandLine.Option( order = 3, names = ["--device-model"], description = [ "Device model to run against", "iOS: iPhone-11, iPhone-11-Pro, etc. Run command: maestro list-devices", "Android: pixel_6, pixel_7, etc. Run command: maestro list-devices" ], ) private var deviceModel: String? = null @CommandLine.Option( order = 4, names = ["--device-os"], description = [ "OS version to use:", "iOS: iOS-16-2, iOS-17-5, iOS-18-2, etc. maestro list-devices", "Android: android-33, android-34, etc. maestro list-devices" ], ) private var deviceOs: String? = null @CommandLine.Option( order = 5, names = ["--force-create"], description = ["Will override existing device if it already exists"], ) private var forceCreate: Boolean = false override fun call(): Int { TestDebugReporter.install(null, printToConsole = parent?.verbose == true) if (EnvUtils.isWSL()) { throw CliError("This command is not supported in Windows WSL. You can launch your emulator manually.") } // Get the device configuration val parsedPlatform = Platform.fromString(platform) val maestroDeviceConfiguration = DeviceSpec.fromRequest( when (parsedPlatform) { Platform.ANDROID -> DeviceSpecRequest.Android( model = deviceModel, os = deviceOs ?: osVersion.let { "android-$it" }, locale = deviceLocale, cpuArchitecture = EnvUtils.getMacOSArchitecture(), ) Platform.IOS -> DeviceSpecRequest.Ios( model = deviceModel, os = deviceOs ?: osVersion.let { "iOS-$it" }, locale = deviceLocale, ) Platform.WEB -> DeviceSpecRequest.Web( model = deviceModel, os = deviceOs ?: osVersion, locale = deviceLocale, ) } ) // Get/Create the device val device = DeviceCreateUtil.getOrCreateDevice( maestroDeviceConfiguration, forceCreate ) // Start Device DeviceService.startDevice( device = device, driverHostPort = parent?.port ) return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/StudioCommand.kt ================================================ package maestro.cli.command import maestro.cli.App import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.report.TestDebugReporter import maestro.cli.session.MaestroSessionManager import maestro.cli.view.blue import maestro.cli.view.bold import maestro.cli.view.box import maestro.cli.view.faint import maestro.studio.MaestroStudio import picocli.CommandLine import java.awt.Desktop import java.net.URI import java.util.concurrent.Callable import maestro.cli.util.getFreePort import picocli.CommandLine.Option @CommandLine.Command( name = "studio", hidden = true, description = ["Launch Maestro Studio"], ) class StudioCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @Option( names = ["--debug-output"], description = ["Configures the debug output in this path, instead of default"] ) private var debugOutput: String? = null @Option( names = ["--no-window"], description = ["When set, a browser window will not be automatically opened"] ) private var noWindow: Boolean? = null @Option( names = ["--android-webview-hierarchy"], description = ["Set to \"devtools\" to use Chrome dev tools for Android WebView hierarchy"], hidden = true, ) private var androidWebViewHierarchy: String? = null @Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."] ) private var appleTeamId: String? = null override fun call(): Int { println() println(""" ╭────────────────────────────────────────────────────────────────────────────────╮ │ │ │ Download the new and improved Maestro Studio app today! │ │ │ │ https://maestro.dev?utm_source=cli&utm_campaign=download_studio#maestro-studio │ │ │ ╰────────────────────────────────────────────────────────────────────────────────╯""".trimIndent().bold()) println() TestDebugReporter.install(debugOutputPathAsString = debugOutput, printToConsole = parent?.verbose == true) MaestroSessionManager.newSession( host = parent?.host, port = parent?.port, driverHostPort = null, teamId = appleTeamId, deviceId = parent?.deviceId, platform = parent?.platform, isStudio = true, ) { session -> session.maestro.setAndroidChromeDevToolsEnabled(androidWebViewHierarchy == "devtools") val port = getFreePort() MaestroStudio.start(port, session.maestro) val studioUrl = "http://localhost:${port}" val message = ("Maestro Studio".bold() + " is running at " + studioUrl.blue()).box() println() println(message) tryOpenUrl(studioUrl) println() println("Tip: Maestro Studio can now run simultaneously alongside other Maestro CLI commands!") println() println("Navigate to $studioUrl in your browser to open Maestro Studio. Ctrl-C to exit.".faint()) Thread.currentThread().join() } TestDebugReporter.deleteOldFiles() return 0 } private fun tryOpenUrl(studioUrl: String) { try { if (Desktop.isDesktopSupported() && noWindow != true) { Desktop.getDesktop().browse(URI(studioUrl)) } } catch (ignore: Exception) { // Do nothing } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.command import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import maestro.Maestro import maestro.cli.App import maestro.cli.CliError import maestro.cli.DisableAnsiMixin import maestro.cli.ShowHelpMixin import maestro.cli.analytics.Analytics import maestro.cli.analytics.TestRunFailedEvent import maestro.cli.analytics.TestRunFinishedEvent import maestro.cli.analytics.TestRunStartedEvent import maestro.cli.analytics.WorkspaceRunFailedEvent import maestro.cli.analytics.WorkspaceRunFinishedEvent import maestro.cli.analytics.WorkspaceRunStartedEvent import maestro.device.Device import maestro.device.DeviceService import maestro.cli.model.TestExecutionSummary import maestro.cli.report.ReportFormat import maestro.cli.report.ReporterFactory import maestro.cli.report.TestDebugReporter import maestro.cli.runner.TestRunner import maestro.cli.runner.TestSuiteInteractor import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.runner.resultview.PlainTextResultView import maestro.cli.session.MaestroSessionManager import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.FileUtils.isWebFlow import maestro.cli.util.PrintUtils import maestro.cli.insights.TestAnalysisManager import maestro.cli.view.greenBox import maestro.cli.view.box import maestro.cli.view.green import maestro.cli.api.ApiClient import maestro.cli.auth.Auth import maestro.cli.model.FlowStatus import maestro.cli.view.cyan import maestro.cli.promotion.PromotionStateManager import maestro.orchestra.error.ValidationError import maestro.orchestra.workspace.WorkspaceExecutionPlanner import maestro.orchestra.workspace.WorkspaceExecutionPlanner.ExecutionPlan import maestro.utils.isSingleFile import okio.sink import org.slf4j.LoggerFactory import picocli.CommandLine import picocli.CommandLine.Option import java.io.File import java.nio.file.Path import java.time.LocalDate import java.util.concurrent.Callable import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.absolutePathString import kotlin.math.roundToInt import maestro.device.Platform @CommandLine.Command( name = "test", description = ["Test a Flow or set of Flows on a local iOS Simulator or Android Emulator"], ) class TestCommand : Callable { @CommandLine.Mixin var disableANSIMixin: DisableAnsiMixin? = null @CommandLine.Mixin var showHelpMixin: ShowHelpMixin? = null @CommandLine.ParentCommand private val parent: App? = null @CommandLine.Parameters(description = ["One or more flow files or folders containing flow files"], arity = "1..*") private var flowFiles: Set = emptySet() @Option( names = ["--config"], description = ["Optional YAML configuration file for the workspace. If not provided, Maestro will look for a config.yaml file in the workspace's root directory."] ) private var configFile: File? = null @Option( names = ["-s", "--shards"], description = ["Number of parallel shards to distribute tests across"], ) @Deprecated("Use --shard-split or --shard-all instead") private var legacyShardCount: Int? = null @Option( names = ["--shard-split"], description = ["Run the tests across N connected devices, splitting the tests evenly across them"], ) private var shardSplit: Int? = null @Option( names = ["--shard-all"], description = ["Run all the tests across N connected devices"], ) private var shardAll: Int? = null @Option(names = ["-c", "--continuous"]) private var continuous: Boolean = false @Option(names = ["-e", "--env"]) private var env: Map = emptyMap() @Option( names = ["--format"], description = ["Test report format (default=\${DEFAULT-VALUE}): \${COMPLETION-CANDIDATES}"], converter = [ReportFormat.Converter::class] ) private var format: ReportFormat = ReportFormat.NOOP @Option( names = ["--test-suite-name"], description = ["Test suite name"], ) private var testSuiteName: String? = null @Option(names = ["--output"]) private var output: File? = null @Option( names = ["--debug-output"], description = ["Configures the debug output in this path, instead of default"], ) private var debugOutput: String? = null @Option( names = ["--test-output-dir"], description = ["Configures the test output directory for screenshots and other test artifacts (note: this does NOT include debug output)"], ) private var testOutputDir: String? = null @Option( names = ["--flatten-debug-output"], description = ["All file outputs from the test case are created in the folder without subfolders or timestamps for each run. It can be used with --debug-output. Useful for CI."] ) private var flattenDebugOutput: Boolean = false @Option( names = ["--include-tags"], description = ["List of tags that will remove the Flows that does not have the provided tags"], split = ",", ) private var includeTags: List = emptyList() @Option( names = ["--exclude-tags"], description = ["List of tags that will remove the Flows containing the provided tags"], split = ",", ) private var excludeTags: List = emptyList() @Option( names = ["--headless"], description = ["(Web only) Run the tests in headless mode"], ) private var headless: Boolean = false @Option( names = ["--screen-size"], description = ["(Web only) Set the size of the headless browser. Use the format {Width}x{Height}. Usage is --screen-size 1920x1080"], ) private var screenSize: String? = null @Option( names = ["--analyze"], description = ["[Beta] Enhance the test output analysis with AI Insights"], ) private var analyze: Boolean = false @Option(names = ["--api-url"], description = ["[Beta] API base URL"]) private var apiUrl: String = "https://api.copilot.mobile.dev" @Option(names = ["--api-key"], description = ["[Beta] API key"]) private var apiKey: String? = null private val client: ApiClient = ApiClient(baseUrl = apiUrl) private val auth: Auth = Auth(client) private val authToken: String? = auth.getAuthToken(apiKey, triggerSignIn = false) @Option( names = ["--reinstall-driver"], description = ["Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation."], negatable = true, defaultValue = "true", fallbackValue = "true" ) private var reinstallDriver: Boolean = true @Option( names = ["--apple-team-id"], description = ["The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account."], hidden = true ) private var appleTeamId: String? = null @Option(names = ["-p", "--platform"], description = ["Select a platform to run on"]) var platform: String? = null @Option( names = ["--device", "--udid"], description = ["Device ID to run on explicitly, can be a comma separated list of IDs: --device \"Emulator_1,Emulator_2\" "], ) var deviceId: String? = null @CommandLine.Spec lateinit var commandSpec: CommandLine.Model.CommandSpec private val usedPorts = ConcurrentHashMap() private val logger = LoggerFactory.getLogger(TestCommand::class.java) internal fun executionPlanIncludesWebFlow(plan: ExecutionPlan): Boolean { return plan.flowsToRun.any { it.toFile().isWebFlow() } || plan.sequence.flows.any { it.toFile().isWebFlow() } } internal fun allFlowsAreWebFlow(plan: ExecutionPlan): Boolean { if(plan.flowsToRun.isEmpty() && plan.sequence.flows.isEmpty()) return false return (plan.flowsToRun.all { it.toFile().isWebFlow() } && plan.sequence.flows.all { it.toFile().isWebFlow() }) } override fun call(): Int { TestDebugReporter.install( debugOutputPathAsString = debugOutput, flattenDebugOutput = flattenDebugOutput, printToConsole = parent?.verbose == true, ) if (shardSplit != null && shardAll != null) { throw CliError("Options --shard-split and --shard-all are mutually exclusive.") } @Suppress("DEPRECATION") if (legacyShardCount != null) { PrintUtils.warn("--shards option is deprecated and will be removed in the next Maestro version. Use --shard-split or --shard-all instead.") shardSplit = legacyShardCount } if (configFile != null && configFile?.exists()?.not() == true) { throw CliError("The config file ${configFile?.absolutePath} does not exist.") } if (screenSize != null && !screenSize!!.matches(Regex("\\d+x\\d+"))) { throw CliError("Invalid screen size format. Please use the format {Width}x{Height}, e.g. 1920x1080.") } val executionPlan = try { WorkspaceExecutionPlanner.plan( input = flowFiles.map { it.toPath().toAbsolutePath() }.toSet(), includeTags = includeTags, excludeTags = excludeTags, config = configFile?.toPath()?.toAbsolutePath(), ) } catch (e: ValidationError) { throw CliError(e.message) } val resolvedTestOutputDir = resolveTestOutputDir(executionPlan) // Update TestDebugReporter with the resolved test output directory TestDebugReporter.updateTestOutputDir(resolvedTestOutputDir) val debugOutputPath = TestDebugReporter.getDebugOutputPath() // Track test execution start val flowCount = executionPlan.flowsToRun.size val platform = parent?.platform ?: "unknown" val deviceCount = getDeviceCount(executionPlan) val result = try { handleSessions(debugOutputPath, executionPlan, resolvedTestOutputDir) } catch (e: Exception) { // Track workspace failure for runtime errors if (flowCount > 1) { Analytics.trackEvent(WorkspaceRunFailedEvent( error = e.message ?: "Unknown error occurred during workspace execution", flowCount = flowCount, platform = platform, deviceCount = deviceCount, )) } else { Analytics.trackEvent(TestRunFailedEvent( error = e.message ?: "Unknown error occurred during workspace execution", platform = platform, )) } throw e } // Flush analytics events immediately after tracking the upload finished event Analytics.flush() return result } /** * Get the actual number of devices that will be used for test execution */ private fun getDeviceCount(plan: ExecutionPlan): Int { val deviceIds = getDeviceIds(plan) return deviceIds.size } /** * Get the list of device IDs that will be used for test execution */ private fun getDeviceIds(plan: ExecutionPlan): List { val includeWeb = executionPlanIncludesWebFlow(plan) val connectedDevices = DeviceService.listConnectedDevices( includeWeb = includeWeb, host = parent?.host, port = parent?.port, ) val availableDevices = connectedDevices.map { it.instanceId }.toSet() return getPassedOptionsDeviceIds(plan) .filter { device -> device in availableDevices } .ifEmpty { availableDevices } .toList() } private fun resolveTestOutputDir(plan: ExecutionPlan): Path? { // Command line flag takes precedence testOutputDir?.let { return File(it).toPath() } // Then check workspace config plan.workspaceConfig.testOutputDir?.let { return File(it).toPath() } // No test output directory configured return null } private fun handleSessions(debugOutputPath: Path, plan: ExecutionPlan, testOutputDir: Path?): Int = runBlocking(Dispatchers.IO) { val requestedShards = shardSplit ?: shardAll ?: 1 if (requestedShards > 1 && plan.sequence.flows.isNotEmpty()) { error("Cannot run sharded tests with sequential execution") } val onlySequenceFlows = plan.sequence.flows.isNotEmpty() && plan.flowsToRun.isEmpty() // An edge case val includeWeb = executionPlanIncludesWebFlow(plan); if (includeWeb) { PrintUtils.warn("Web support is in Beta. We would appreciate your feedback!\n") } val connectedDevices = DeviceService.listConnectedDevices( includeWeb = includeWeb, host = parent?.host, port = parent?.port, ) val availableDevicesIds = connectedDevices.map { it.instanceId }.toSet() val deviceIds = getPassedOptionsDeviceIds(plan) .filter { device -> if (device !in availableDevicesIds) { throw CliError("Device $device was requested, but it is not connected.") } else { true } } .ifEmpty { val platform = platform ?: parent?.platform connectedDevices .filter { platform == null || it.platform == Platform.fromString(platform) } .map { it.instanceId }.toSet() } .toList() val missingDevices = requestedShards - deviceIds.size if (missingDevices > 0) { PrintUtils.warn("You have ${deviceIds.size} devices connected, which is not enough to run $requestedShards shards. Missing $missingDevices device(s).") throw CliError("Not enough devices connected (${deviceIds.size}) to run the requested number of shards ($requestedShards).") } val effectiveShards = when { onlySequenceFlows -> 1 shardAll == null -> requestedShards.coerceAtMost(plan.flowsToRun.size) shardSplit == null -> requestedShards.coerceAtMost(deviceIds.size) else -> 1 } val warning = "Requested $requestedShards shards, " + "but it cannot be higher than the number of flows (${plan.flowsToRun.size}). " + "Will use $effectiveShards shards instead." if (shardAll == null && requestedShards > plan.flowsToRun.size) PrintUtils.warn(warning) val chunkPlans = makeChunkPlans(plan, effectiveShards, onlySequenceFlows) val flowCount = if (onlySequenceFlows) plan.sequence.flows.size else plan.flowsToRun.size val message = when { shardAll != null -> "Will run $effectiveShards shards, with all $flowCount flows in each shard" shardSplit != null -> { val flowsPerShard = (flowCount.toFloat() / effectiveShards).roundToInt() val isApprox = flowCount % effectiveShards != 0 val prefix = if (isApprox) "approx. " else "" "Will split $flowCount flows across $effectiveShards shards (${prefix}$flowsPerShard flows per shard)" } else -> null } message?.let { PrintUtils.info(it) } // Show cloud promotion message if there are more than 5 tests (at most once per day) if (flowCount > 5) { showCloudFasterResultsPromotionMessageIfNeeded() } val results = (0 until effectiveShards).map { shardIndex -> async(Dispatchers.IO + CoroutineName("shard-$shardIndex")) { runShardSuite( effectiveShards = effectiveShards, deviceIds = deviceIds, shardIndex = shardIndex, chunkPlans = chunkPlans, debugOutputPath = debugOutputPath, testOutputDir = testOutputDir, ) } }.awaitAll() val passed = results.sumOf { it.first ?: 0 } val total = results.sumOf { it.second ?: 0 } val suites = results.mapNotNull { it.third } // Show cloud debug promotion message if there are failures if (passed != total) { showCloudDebugPromotionMessageIfNeeded() } suites.mergeSummaries()?.saveReport() if (effectiveShards > 1) printShardsMessage(passed, total, suites) if (analyze) TestAnalysisManager(apiUrl = apiUrl, apiKey = apiKey).runAnalysis(debugOutputPath) if (passed == total) 0 else 1 } private fun runShardSuite( effectiveShards: Int, deviceIds: List, shardIndex: Int, chunkPlans: List, debugOutputPath: Path, testOutputDir: Path?, ): Triple { val driverHostPort = selectPort(effectiveShards) val deviceId = deviceIds[shardIndex] val executionPlan = chunkPlans[shardIndex] logger.info("[shard ${shardIndex + 1}] Selected device $deviceId using port $driverHostPort with execution plan $executionPlan") return MaestroSessionManager.newSession( host = parent?.host, port = parent?.port, teamId = appleTeamId, driverHostPort = driverHostPort, deviceId = deviceId, platform = platform ?: parent?.platform, isHeadless = headless, screenSize = screenSize, reinstallDriver = reinstallDriver, executionPlan = executionPlan ) { session -> val maestro = session.maestro val device = session.device val isReplicatingSingleFile = shardAll != null && effectiveShards > 1 && flowFiles.isSingleFile val isMultipleFiles = flowFiles.isSingleFile.not() val isAskingForReport = format != ReportFormat.NOOP if (isMultipleFiles || isAskingForReport || isReplicatingSingleFile) { if (continuous) { throw CommandLine.ParameterException( commandSpec.commandLine(), "Continuous mode is not supported when running multiple flows. (${flowFiles.joinToString(", ")})", ) } runBlocking { runMultipleFlows( maestro, device, chunkPlans, shardIndex, debugOutputPath, testOutputDir, deviceId, ) } } else { val flowFile = flowFiles.first() if (continuous) { if (!flattenDebugOutput) { TestDebugReporter.deleteOldFiles() } TestRunner.runContinuous( maestro, device, flowFile, env, analyze, authToken, testOutputDir, deviceId, ) } else { runSingleFlow(maestro, device, flowFile, debugOutputPath, testOutputDir, deviceId) } } } } private fun selectPort(effectiveShards: Int): Int = if (effectiveShards == 1) 7001 else (7001..7128).shuffled().find { port -> usedPorts.putIfAbsent(port, true) == null } ?: error("No available ports found") private fun runSingleFlow( maestro: Maestro, device: Device?, flowFile: File, debugOutputPath: Path, testOutputDir: Path?, deviceId: String?, ): Triple { val resultView = if (DisableAnsiMixin.ansiEnabled) { AnsiResultView(useEmojis = !EnvUtils.isWindows()) } else { PlainTextResultView() } val startTime = System.currentTimeMillis() Analytics.trackEvent(TestRunStartedEvent( platform = device?.platform.toString() )) val resultSingle = TestRunner.runSingle( maestro = maestro, device = device, flowFile = flowFile, env = env, resultView = resultView, debugOutputPath = debugOutputPath, analyze = analyze, apiKey = authToken, testOutputDir = testOutputDir, deviceId = deviceId, ) val duration = System.currentTimeMillis() - startTime if (resultSingle == 1) { printExitDebugMessage() } Analytics.trackEvent( TestRunFinishedEvent( status = if (resultSingle == 0) FlowStatus.SUCCESS else FlowStatus.ERROR, platform = device?.platform.toString(), durationMs = duration ) ) if (!flattenDebugOutput) { TestDebugReporter.deleteOldFiles() } val result = if (resultSingle == 0) 1 else 0 return Triple(result, 1, null) } private suspend fun runMultipleFlows( maestro: Maestro, device: Device?, chunkPlans: List, shardIndex: Int, debugOutputPath: Path, testOutputDir: Path?, deviceId: String?, ): Triple { val startTime = System.currentTimeMillis() val totalFlowCount = chunkPlans.sumOf { it.flowsToRun.size } Analytics.trackEvent(WorkspaceRunStartedEvent( flowCount = totalFlowCount, platform = parent?.platform.toString(), deviceCount = chunkPlans.size )) val suiteResult = TestSuiteInteractor( maestro = maestro, device = device, shardIndex = if (chunkPlans.size == 1) null else shardIndex, reporter = ReporterFactory.buildReporter(format, testSuiteName), captureSteps = format == ReportFormat.HTML_DETAILED, ).runTestSuite( executionPlan = chunkPlans[shardIndex], env = env, reportOut = null, debugOutputPath = debugOutputPath, testOutputDir = testOutputDir, deviceId = deviceId, ) val duration = System.currentTimeMillis() - startTime if (!flattenDebugOutput) { TestDebugReporter.deleteOldFiles() } Analytics.trackEvent( WorkspaceRunFinishedEvent( flowCount = totalFlowCount, deviceCount = chunkPlans.size, platform = parent?.platform.toString(), durationMs = duration ) ) return Triple(suiteResult.passedCount, suiteResult.totalTests, suiteResult) } private fun makeChunkPlans( plan: ExecutionPlan, effectiveShards: Int, onlySequenceFlows: Boolean, ) = when { onlySequenceFlows -> listOf(plan) // We only want to run sequential flows in this case. shardAll != null -> (0 until effectiveShards).reversed().map { plan.copy() } else -> plan.flowsToRun .withIndex() .groupBy { it.index % effectiveShards } .map { (_, files) -> val flowsToRun = files.map { it.value } ExecutionPlan(flowsToRun, plan.sequence, plan.workspaceConfig) } } private fun getPassedOptionsDeviceIds(plan: ExecutionPlan): List { val arguments = if (allFlowsAreWebFlow(plan)) { "chromium" } else deviceId ?: parent?.deviceId val deviceIds = arguments .orEmpty() .split(",") .map { it.trim() } .filter { it.isNotBlank() } return deviceIds } private fun printExitDebugMessage() { println() println("==== Debug output (logs & screenshots) ====") PrintUtils.message(TestDebugReporter.getDebugOutputPath().absolutePathString()) } private fun printShardsMessage(passedTests: Int, totalTests: Int, shardResults: List) { val lines = listOf("Passed: $passedTests/$totalTests") + shardResults.mapIndexed { _, result -> "[ ${result.suites.first().deviceName} ] - ${result.passedCount ?: 0}/${result.totalTests ?: 0}" } PrintUtils.message(lines.joinToString("\n").box()) } private fun TestExecutionSummary.saveReport() { val reporter = ReporterFactory.buildReporter(format, testSuiteName) format.fileExtension?.let { extension -> (output ?: File("report$extension")).sink() }?.also { sink -> reporter.report(this, sink) } } private fun List.mergeSummaries(): TestExecutionSummary? = reduceOrNull { acc, summary -> TestExecutionSummary( passed = acc.passed && summary.passed, suites = acc.suites + summary.suites, passedCount = sumOf { it.passedCount ?: 0 }, totalTests = sumOf { it.totalTests ?: 0 } ) } private fun showCloudFasterResultsPromotionMessageIfNeeded() { // Don't show in CI environments if (CiUtils.getCiProvider() != null) { return } val promotionStateManager = PromotionStateManager() val today = LocalDate.now().toString() // Don't show if already shown today if (promotionStateManager.getLastShownDate("fasterResults") == today) { return } // Don't show if user has used cloud command within last 3 days if (promotionStateManager.wasCloudCommandUsedWithinDays(3)) { return } val command = "maestro cloud app_file flows_folder/" val message = "Get results faster by ${"executing flows in parallel".cyan()} on Maestro Cloud virtual devices. Run: \n${command.green()}" PrintUtils.info(message.greenBox()) promotionStateManager.setLastShownDate("fasterResults", today) } private fun showCloudDebugPromotionMessageIfNeeded() { // Don't show in CI environments if (CiUtils.getCiProvider() != null) { return } val promotionStateManager = PromotionStateManager() val today = LocalDate.now().toString() // Don't show if already shown today if (promotionStateManager.getLastShownDate("debug") == today) { return } // Don't show if user has used cloud command within last 3 days if (promotionStateManager.wasCloudCommandUsedWithinDays(3)) { return } val command = "maestro cloud app_file flows_folder/" val message = "Debug tests faster by easy access to ${"test recordings, maestro logs, screenshots, and more".cyan()}.\n\nRun your flows on Maestro Cloud:\n${command.green()}" PrintUtils.info(message.greenBox()) promotionStateManager.setLastShownDate("debug", today) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/db/KeyValueStore.kt ================================================ package maestro.cli.db import java.io.File import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read import kotlin.concurrent.write class KeyValueStore(private val dbFile: File) { private val lock = ReentrantReadWriteLock() init { dbFile.createNewFile() } fun get(key: String): String? = lock.read { getCurrentDB()[key] } fun set(key: String, value: String) = lock.write { val db = getCurrentDB() db[key] = value commit(db) } fun delete(key: String) = lock.write { val db = getCurrentDB() db.remove(key) commit(db) } fun keys(): List = lock.read { getCurrentDB().keys.toList() } private fun getCurrentDB(): MutableMap { return dbFile .readLines() .associate { line -> val (key, value) = line.split("=", limit = 2) key to value } .toMutableMap() } private fun commit(db: MutableMap) { dbFile.writeText( db.map { (key, value) -> "$key=$value" } .joinToString("\n") ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/device/DeviceCreateUtil.kt ================================================ package maestro.cli.device import maestro.device.DeviceService import maestro.device.Device import maestro.device.Platform import maestro.cli.CliError import maestro.cli.util.* import maestro.device.DeviceSpec object DeviceCreateUtil { fun getOrCreateDevice( deviceSpec: DeviceSpec, forceCreate: Boolean = false, shardIndex: Int? = null, ): Device.AvailableForLaunch = when (deviceSpec) { is DeviceSpec.Android -> getOrCreateAndroidDevice(deviceSpec, forceCreate, shardIndex) is DeviceSpec.Ios -> getOrCreateIosDevice(deviceSpec, forceCreate, shardIndex) is DeviceSpec.Web -> Device.AvailableForLaunch( platform = Platform.WEB, description = "Chromium Desktop Browser (Experimental)", modelId = deviceSpec.model, deviceType = Device.DeviceType.BROWSER, deviceSpec = deviceSpec, ) } fun getOrCreateIosDevice( deviceSpec: DeviceSpec.Ios, forceCreate: Boolean, shardIndex: Int? = null ): Device.AvailableForLaunch { // check connected device if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.IOS) != null && shardIndex == null && !forceCreate) { throw CliError("A device with name ${deviceSpec.deviceName} is already connected") } // check existing device val existingDeviceId = DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.IOS)?.let { if (forceCreate) { DeviceService.deleteIosDevice(it.modelId) null } else it.modelId } if (existingDeviceId != null) PrintUtils.message("Using existing device ${deviceSpec.deviceName} (${existingDeviceId}).") else PrintUtils.message("Attempting to create iOS simulator: ${deviceSpec.deviceName} ") val deviceUUID = existingDeviceId ?: try { // To find the closest matching os: "iOS-18" -> "iOS-18-2", "iOS-17" -> "iOS-17-5" val closestInstalledRuntime = DeviceService.listIOSDevices().firstOrNull { it.deviceSpec.os.startsWith(deviceSpec.os) }?.deviceSpec?.os ?: deviceSpec.os // Start the device DeviceService.createIosDevice(deviceSpec.deviceName, deviceSpec.model, closestInstalledRuntime).toString() } catch (e: IllegalStateException) { val error = e.message ?: "" if (error.contains("Invalid runtime")) { val msg = """ Required runtime to create the simulator is not installed: ${deviceSpec.os} To install additional iOS runtimes checkout this guide: * https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes """.trimIndent() throw CliError(msg) } else if (error.contains("xcrun: error: unable to find utility \"simctl\"")) { val msg = """ The xcode-select CLI tools are not installed, install with xcode-select --install If the xcode-select CLI tools are already installed, the path may be broken. Try running sudo xcode-select -r to repair the path and re-run this command """.trimIndent() throw CliError(msg) } else if (error.contains("Invalid device type")) { throw CliError("Device type ${deviceSpec.model} is either not supported or not found.") } else { throw CliError(error) } } if (existingDeviceId == null) PrintUtils.message("Created simulator with name ${deviceSpec.deviceName} and UUID $deviceUUID") return Device.AvailableForLaunch( modelId = deviceUUID, description = deviceSpec.deviceName, platform = Platform.IOS, deviceType = Device.DeviceType.SIMULATOR, deviceSpec = deviceSpec, ) } fun getOrCreateAndroidDevice( deviceSpec: DeviceSpec.Android, forceCreate: Boolean, shardIndex: Int? = null ): Device.AvailableForLaunch { val systemImage = deviceSpec.emulatorImage // check connected device if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.ANDROID) != null && shardIndex == null && !forceCreate) throw CliError("A device with name ${deviceSpec.deviceName} is already connected") // existing device val existingDevice = if (forceCreate) null else DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.ANDROID)?.modelId // dependencies if (existingDevice == null && !DeviceService.isAndroidSystemImageInstalled(systemImage)) { PrintUtils.err("The required system image $systemImage is not installed.") PrintUtils.message("Would you like to install it? y/n") val r = readlnOrNull()?.lowercase() if (r == "y" || r == "yes") { PrintUtils.message("Attempting to install $systemImage via Android SDK Manager...\n") if (!DeviceService.installAndroidSystemImage(systemImage)) { val message = """ Unable to install required dependencies. You can install the system image manually by running this command: ${DeviceService.getAndroidSystemImageInstallCommand(systemImage)} """.trimIndent() throw CliError(message) } } else { val message = """ To install the system image manually, you can run this command: ${DeviceService.getAndroidSystemImageInstallCommand(systemImage)} """.trimIndent() throw CliError(message) } } if (existingDevice != null) PrintUtils.message("Using existing device ${deviceSpec.deviceName}.") else PrintUtils.message("Attempting to create Android emulator: ${deviceSpec.deviceName} ") val deviceLaunchId = try { existingDevice ?: DeviceService.createAndroidDevice( deviceName = deviceSpec.deviceName, device = deviceSpec.model, systemImage = systemImage, tag = deviceSpec.tag, abi = deviceSpec.cpuArchitecture.value, force = forceCreate, ) } catch (e: IllegalStateException) { throw CliError("${e.message}") } if (existingDevice == null) PrintUtils.message("Created Android emulator: ${deviceSpec.deviceName} ($systemImage)") return Device.AvailableForLaunch( modelId = deviceLaunchId, description = deviceLaunchId, platform = Platform.ANDROID, deviceType = Device.DeviceType.EMULATOR, deviceSpec = deviceSpec, ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/device/PickDeviceInteractor.kt ================================================ package maestro.cli.device import maestro.cli.CliError import maestro.device.DeviceService import maestro.device.DeviceService.withPlatform import maestro.device.Device import maestro.device.Platform import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils object PickDeviceInteractor { fun pickDevice( deviceId: String? = null, driverHostPort: Int? = null, platform: Platform? = null, deviceIndex: Int? = null, ): Device.Connected { if (deviceId != null) { return DeviceService.listConnectedDevices() .find { it.instanceId.equals(deviceId, ignoreCase = true) } ?: throw CliError("Device with id $deviceId is not connected") } return pickDeviceInternal(platform, deviceIndex) .let { pickedDevice -> var result: Device = pickedDevice if (result is Device.AvailableForLaunch) { when (result.deviceSpec.platform) { Platform.ANDROID -> PrintUtils.message("Launching Android emulator...") Platform.IOS -> PrintUtils.message("Launching iOS simulator...") Platform.WEB -> PrintUtils.message("Launching ${result.description}") } result = DeviceService.startDevice(result, driverHostPort) } if (result !is Device.Connected) { error("Device $result is not connected") } result } } private fun pickDeviceInternal(platform: Platform?, selectedIndex: Int? = null): Device { val connectedDevices = DeviceService.listConnectedDevices().withPlatform(platform) val selected = if(selectedIndex != null) { selectedIndex } else if (connectedDevices.size == 1) { 0 } else { null } if (selected != null) { val device = connectedDevices[selected] PickDeviceView.showRunOnDevice(device) return device } if (connectedDevices.isEmpty()) { return startDevice(platform) } return pickRunningDevice(connectedDevices) } private fun startDevice(platform: Platform?): Device { if (EnvUtils.isWSL()) { throw CliError("No running emulator found. Start an emulator manually and try again.\nFor setup info checkout: https://maestro.mobile.dev/getting-started/installing-maestro/windows") } PrintUtils.message("No running devices found. Launch a device manually or select a number from the options below:\n") PrintUtils.message("[1] Start or create a Maestro recommended device\n[2] List existing devices\n[3] Quit") val input = readlnOrNull()?.lowercase()?.trim() when(input) { "1" -> { PrintUtils.clearConsole() val maestroDeviceConfiguration = PickDeviceView.requestDeviceOptions(platform) return DeviceCreateUtil.getOrCreateDevice(maestroDeviceConfiguration, false) } "2" -> { PrintUtils.clearConsole() val availableDevices = DeviceService.listAvailableForLaunchDevices().withPlatform(platform) if (availableDevices.isEmpty()) { throw CliError("No devices available. To proceed, either install Android SDK or Xcode.") } return PickDeviceView.pickDeviceToStart(availableDevices) } else -> { throw CliError("Please either start a device manually or via running maestro start-device to proceed running your flows") } } } private fun pickRunningDevice(devices: List): Device { return PickDeviceView.pickRunningDevice(devices) } } fun main() { println(PickDeviceInteractor.pickDevice()) println("Ready") while (!Thread.interrupted()) { Thread.sleep(1000) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/device/PickDeviceView.kt ================================================ package maestro.cli.device import maestro.cli.CliError import maestro.cli.util.PrintUtils import maestro.device.Device import maestro.device.DeviceSpecRequest import maestro.device.DeviceSpec import maestro.device.Platform import org.fusesource.jansi.Ansi.ansi object PickDeviceView { fun showRunOnDevice(device: Device) { println("Running on ${device.description}") } fun pickDeviceToStart(devices: List): Device { printIndexedDevices(devices) println("Choose a device to boot and run on.") printEnterNumberPrompt() return pickIndex(devices) } fun requestDeviceOptions(platform: Platform? = null): DeviceSpec { PrintUtils.message("Please specify a device platform [android, ios, web]:") val selectedPlatform = platform ?: (readlnOrNull()?.lowercase()?.let { Platform.fromString(it) } ?: throw CliError("Please specify a platform")) val spec = DeviceSpec.fromRequest( when (selectedPlatform) { Platform.ANDROID -> DeviceSpecRequest.Android() Platform.IOS -> DeviceSpecRequest.Ios() Platform.WEB -> DeviceSpecRequest.Web() } ) return spec } fun pickRunningDevice(devices: List): Device { printIndexedDevices(devices) println("Multiple running devices detected. Choose a device to run on.") printEnterNumberPrompt() return pickIndex(devices) } private fun pickIndex(data: List): T { println() while (!Thread.interrupted()) { val index = readlnOrNull()?.toIntOrNull() ?: 0 if (index < 1 || index > data.size) { printEnterNumberPrompt() continue } return data[index - 1] } error("Interrupted") } private fun printEnterNumberPrompt() { println() println("Enter a number from the list above:") } private fun printIndexedDevices(devices: List) { val devicesByPlatform = devices.groupBy { it.platform } var index = 0 devicesByPlatform.forEach { (platform, devices) -> println(platform.description) println() devices.forEach { device -> println( ansi() .render("[") .fgCyan() .render("${++index}") .fgDefault() .render("] ${device.description}") ) } println() } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/driver/DriverBuildConfig.kt ================================================ package maestro.cli.driver import maestro.cli.api.CliVersion data class DriverBuildConfig( val teamId: String, val derivedDataPath: String, val sourceCodePath: String = "driver/ios", val sourceCodeRoot: String = System.getProperty("user.home"), val destination: String = "generic/platform=iphoneos", val architectures: String = "arm64", val configuration: String = "Debug", val cliVersion: CliVersion? ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/driver/DriverBuilder.kt ================================================ package maestro.cli.driver import maestro.MaestroException import java.io.File import java.nio.file.* import java.util.* import java.util.concurrent.TimeUnit import kotlin.io.path.pathString class DriverBuilder(private val processBuilderFactory: XcodeBuildProcessBuilderFactory = XcodeBuildProcessBuilderFactory()) { private val waitTime: Long by lazy { System.getenv("MAESTRO_XCODEBUILD_WAIT_TIME")?.toLongOrNull() ?: DEFAULT_XCODEBUILD_WAIT_TIME } /** * Builds the iOS driver for real iOS devices by extracting the driver source, copying it to a temporary build * directory, and executing the Xcode build process. The resulting build products are placed in the specified * derived data path. * * @param config A configuration object containing details like team ID, derived data path, destination platform, * architectures, and other parameters required for building the driver. * @return The path to the directory containing build products. * @throws RuntimeException if the build process fails. * * Directory Structure: * 1. workingDirectory (Path): Root working directory for Maestro stored in the user's home directory. * .maestro * |_ maestro-iphoneos-driver-build * |_ driver-iphoneos: Consists the build products to setup iOS driver: maestro-driver-*.xctestrun, * Debug-iphoneos/maestro-driver-iosUITests-Runner.app, and Debug-iphoneos/maestro-driver-ios.app * |_ output.log: In case of errors output.log would be there to help debug * * 2. xcodebuildOutput (Path): A temporary directory created to store the output logs of the xcodebuild process and source code. * It exists only for the duration of the build operation. * e.g., $TMPDIR/maestro-xcodebuild-outputXXXXXX */ fun buildDriver(config: DriverBuildConfig): Path { // Get driver source from resources val driverSourcePath = getDriverSourceFromResources(config) // Create temporary build directory val workingDirectory = Paths.get(config.sourceCodeRoot, ".maestro") val buildDir = Files.createDirectories(workingDirectory.resolve("maestro-iphoneos-driver-build")).apply { // Cleanup directory before we execute the build toFile().deleteRecursively() } val xcodebuildOutput = Files.createTempDirectory("maestro-xcodebuild-output") val outputFile = File(xcodebuildOutput.pathString + "/output.log") try { // Copy driver source to build directory Files.walk(driverSourcePath).use { paths -> paths.filter { Files.isRegularFile(it) }.forEach { path -> val targetPath = xcodebuildOutput.resolve(driverSourcePath.relativize(path).toString()) Files.createDirectories(targetPath.parent) Files.copy(path, targetPath, StandardCopyOption.REPLACE_EXISTING) } } // Create derived data path val derivedDataPath = buildDir.resolve(config.derivedDataPath) Files.createDirectories(derivedDataPath) // Build command val process = processBuilderFactory.createProcess( commands = listOf( "xcodebuild", "clean", "build-for-testing", "-project", "${xcodebuildOutput.pathString}/maestro-driver-ios.xcodeproj", "-scheme", "maestro-driver-ios", "-destination", config.destination, "-allowProvisioningUpdates", "-derivedDataPath", derivedDataPath.toString(), "DEVELOPMENT_TEAM=${config.teamId}", "ARCHS=${config.architectures}", "CODE_SIGN_IDENTITY=Apple Development", ), workingDirectory = workingDirectory.toFile(), outputFile = outputFile ) process.waitFor(waitTime, TimeUnit.SECONDS) if (process.exitValue() != 0) { // copy the error log inside driver output val targetErrorFile = File(buildDir.toFile(), outputFile.name) outputFile.copyTo(targetErrorFile, overwrite = true) throw MaestroException.IOSDeviceDriverSetupException( """ Failed to build iOS driver for connected iOS device. Error details: - Build log: ${targetErrorFile.path} """.trimIndent() ) } // Return path to build products return derivedDataPath.resolve("Build/Products") } finally { File(buildDir.toFile(), "version.properties").writer().use { val p = Properties() p["version"] = config.cliVersion.toString() p.store(it, null) } xcodebuildOutput.toFile().deleteRecursively() } } fun getDriverSourceFromResources(config: DriverBuildConfig): Path { val resourcePath = config.sourceCodePath val resourceUrl = DriverBuilder::class.java.classLoader.getResource(resourcePath) ?: throw IllegalArgumentException("Resource not found: $resourcePath") val uri = resourceUrl.toURI() val path = if (uri.scheme == "jar") { val fs = try { FileSystems.getFileSystem(uri) } catch (e: FileSystemNotFoundException) { FileSystems.newFileSystem(uri, emptyMap()) } fs.getPath("/$resourcePath") } else { Paths.get(uri) } return path } companion object { private const val DEFAULT_XCODEBUILD_WAIT_TIME: Long = 120 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/driver/RealIOSDeviceDriver.kt ================================================ package maestro.cli.driver import maestro.MaestroException import maestro.cli.api.CliVersion import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils.message import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.util.Properties class RealIOSDeviceDriver(private val teamId: String?, private val destination: String, private val driverBuilder: DriverBuilder) { fun validateAndUpdateDriver(driverRootDirectory: Path = getDefaultVersionPropertiesFile(), force: Boolean = false) { val driverDirectory = driverRootDirectory.resolve("maestro-iphoneos-driver-build") val versionPropertiesFile = driverDirectory.resolve("version.properties") val currentCliVersion = EnvUtils.CLI_VERSION ?: throw IllegalStateException("CLI version is unavailable.") if (force) { buildDriver(driverDirectory, message = "Building iOS driver for $destination...") return } if (Files.exists(versionPropertiesFile)) { val properties = Properties().apply { Files.newBufferedReader(versionPropertiesFile).use(this::load) } val localVersion = properties.getProperty("version")?.let { CliVersion.parse(it) } ?: throw IllegalStateException("Invalid or missing version in version.properties.") val products = driverDirectory.resolve("driver-iphoneos").resolve("Build").resolve("Products") val xctestRun = products.toFile().walk().find { it.extension == "xctestrun" } if (currentCliVersion > localVersion) { message("Local version $localVersion of iOS driver is outdated. Updating to latest.") buildDriver(driverDirectory, message = "Validating and updating iOS driver for real iOS device: $destination...") } else if (xctestRun?.exists() == false || xctestRun == null) { message("Drivers for $destination not found, building the drivers.") buildDriver(driverDirectory, message = "Building the drivers for $destination") } } else { buildDriver(driverDirectory, "Building iOS driver for $destination...") } } private fun buildDriver(driverDirectory: Path, message: String) { val spinner = Spinner(message).apply { start() } // Build the new driver val teamId = try { requireNotNull(teamId) { "Apple account team ID must be specified." } } catch (e: IllegalArgumentException) { throw MaestroException.MissingAppleTeamId( "Apple account team ID must be specified to build drivers for connected iPhone." ) } // Cleanup old driver files if necessary if (Files.exists(driverDirectory)) { message("Cleaning up old driver files...") driverDirectory.toFile().deleteRecursively() } driverBuilder.buildDriver( DriverBuildConfig( teamId = teamId, derivedDataPath = "driver-iphoneos", destination = destination, sourceCodePath = "driver/ios", cliVersion = EnvUtils.CLI_VERSION ) ) spinner.stop() message("✅ Drivers successfully set up for destination $destination") } private fun getDefaultVersionPropertiesFile(): Path { val maestroDirectory = Paths.get(System.getProperty("user.home"), ".maestro") return maestroDirectory } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/driver/Spinner.kt ================================================ package maestro.cli.driver class Spinner(private val message: String = "Processing") { private val frames = listOf("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") private var active = true private lateinit var thread: Thread fun start() { thread = Thread { var i = 0 while (active) { print("\r${frames[i % frames.size]} $message") Thread.sleep(100) i++ } } thread.start() } fun stop() { active = false thread.join() print("\r✅ $message\n") } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/driver/XcodeBuildProcessBuilderFactory.kt ================================================ package maestro.cli.driver import java.io.File class XcodeBuildProcessBuilderFactory { fun createProcess(commands: List, workingDirectory: File, outputFile: File): Process { return ProcessBuilder(commands).directory(workingDirectory).redirectOutput(outputFile) .redirectError(outputFile) .start() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/AWTUtils.kt ================================================ package maestro.cli.graphics import org.jcodec.api.FrameGrab import org.jcodec.api.awt.AWTSequenceEncoder import org.jcodec.common.io.NIOUtils import java.awt.Graphics2D import java.io.File fun Graphics2D.use(block: (g: Graphics2D) -> Unit) { try { block(this) } finally { dispose() } } fun AWTSequenceEncoder.use(block: (encoder: AWTSequenceEncoder) -> Unit) { try { block(this) } finally { finish() } } fun useFrameGrab(file: File, block: (grab: FrameGrab) -> Unit) { NIOUtils.readableChannel(file).use { channelIn -> val grab = FrameGrab.createFrameGrab(channelIn) block(grab) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/LocalVideoRenderer.kt ================================================ package maestro.cli.graphics import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.view.ProgressBar import maestro.cli.view.render import okio.ByteString.Companion.decodeBase64 import org.jcodec.api.PictureWithMetadata import org.jcodec.api.awt.AWTSequenceEncoder import org.jcodec.common.io.NIOUtils import org.jcodec.common.model.Rational import org.jcodec.scale.AWTUtil import java.awt.image.BufferedImage import java.io.File interface FrameRenderer { fun render( outputWidthPx: Int, outputHeightPx: Int, screen: BufferedImage, text: String, ): BufferedImage } class LocalVideoRenderer( private val frameRenderer: FrameRenderer, private val outputFile: File, private val outputFPS: Int, private val outputWidthPx: Int, private val outputHeightPx: Int, ) : VideoRenderer { override fun render( screenRecording: File, textFrames: List, ) { System.err.println() System.err.println("@|bold Rendering video - This may take some time...|@".render()) System.err.println() System.err.println(outputFile.absolutePath) val uploadProgress = ProgressBar(50) NIOUtils.writableFileChannel(outputFile.absolutePath).use { out -> AWTSequenceEncoder(out, Rational.R(outputFPS, 1)).use { encoder -> useFrameGrab(screenRecording) { grab -> val outputDurationSeconds = grab.videoTrack.meta.totalDuration val outputFrameCount = (outputDurationSeconds * outputFPS).toInt() var curFrame: PictureWithMetadata = grab.nativeFrameWithMetadata!! var nextFrame: PictureWithMetadata? = grab.nativeFrameWithMetadata (0..outputFrameCount).forEach { frameIndex -> val currentTimestampSeconds = frameIndex.toDouble() / outputFPS // !! Due to smart cast limitation: https://youtrack.jetbrains.com/issue/KT-7186 @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") while (nextFrame != null && nextFrame!!.timestamp <= currentTimestampSeconds) { curFrame = nextFrame!! nextFrame = grab.nativeFrameWithMetadata } val curImage = AWTUtil.toBufferedImage(curFrame.picture) val curTextFrame = textFrames.lastOrNull { frame -> frame.timestamp.div(1000.0) <= currentTimestampSeconds } ?: textFrames.first() val curText = curTextFrame.content.decodeBase64()!!.string(Charsets.UTF_8).stripAnsiCodes() val outputImage = frameRenderer.render(outputWidthPx, outputHeightPx, curImage, curText) encoder.encodeImage(outputImage) uploadProgress.set(frameIndex / outputFrameCount.toFloat()) } } } } System.err.println() System.err.println() System.err.println("Rendering complete! If you're sharing on Twitter be sure to tag us \uD83D\uDE04 @|bold @mobile__dev|@".render()) } private fun String.stripAnsiCodes(): String { return replace("\\u001B\\[[;\\d]*[mH]".toRegex(), "") } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/RemoteVideoRenderer.kt ================================================ package maestro.cli.graphics import maestro.cli.api.ApiClient import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.view.ProgressBar import maestro.cli.view.render import java.io.File class RemoteVideoRenderer : VideoRenderer { override fun render( screenRecording: File, textFrames: List ) { val client = ApiClient("") System.err.println() System.err.println("@|bold ⚠\uFE0F DEPRECATION NOTICE ⚠\uFE0F\nThis method of recording will soon be deprecated and replaced with a local rendering implementation.\nTo switch to (Beta) local rendering, use \"maestro record --local ...\". This will become the default behavior in a future Maestro release.|@".render()) System.err.println() val uploadProgress = ProgressBar(50) System.err.println("Uploading raw files for render...") val id = client.render(screenRecording, textFrames) { totalBytes, bytesWritten -> uploadProgress.set(bytesWritten.toFloat() / totalBytes) } System.err.println() var renderProgress: ProgressBar? = null var status: String? = null var positionInQueue: Int? = null while (true) { val state = client.getRenderState(id) // If new position or status, print header if (state.status != status || state.positionInQueue != positionInQueue) { status = state.status positionInQueue = state.positionInQueue if (renderProgress != null) { renderProgress.set(1f) System.err.println() } System.err.println() System.err.println("Status : ${styledStatus(state.status)}") if (state.positionInQueue != null) { System.err.println("Position In Queue : ${state.positionInQueue}") } } // Add ticks to progress bar if (state.currentTaskProgress != null) { if (renderProgress == null) renderProgress = ProgressBar(50) renderProgress.set(state.currentTaskProgress) } // Print download url or error and return if (state.downloadUrl != null || state.error != null) { System.err.println() if (state.downloadUrl != null) { System.err.println("@|bold Signed Download URL:|@".render()) System.err.println() print("@|cyan,bold ${state.downloadUrl}|@".render()) System.err.println() System.err.println() System.err.println("Open the link above to download your video. If you're sharing on Twitter be sure to tag us @|bold @mobile__dev|@!".render()) } else { System.err.println("@|bold Render encountered during rendering:|@".render()) System.err.println(state.error) } break } Thread.sleep(2000) } } private fun styledStatus(status: String): String { val style = when (status) { "PENDING" -> "yellow,bold" "RENDERING" -> "blue,bold" "SUCCESS" -> "green,bold" else -> "bold" } return "@|$style $status|@".render() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt ================================================ package maestro.cli.graphics import org.jetbrains.skia.Canvas import org.jetbrains.skia.Color import org.jetbrains.skia.Font import org.jetbrains.skia.Paint import org.jetbrains.skia.Rect import org.jetbrains.skia.Surface import org.jetbrains.skiko.toImage import java.awt.image.BufferedImage import javax.imageio.ImageIO class SkiaFrameRenderer : FrameRenderer { private val backgroundImage = ImageIO.read(SkiaFrameRenderer::class.java.getResource("/record-background.jpg")!!).toImage() private val shadowColor = Color.makeARGB(100, 0, 0, 0) private val scenePadding = 40f private val sceneGap = 40f private val headerBgColor = Color.makeARGB(50, 255, 255, 255) private val headerHeight = 60f private val headerFont = Font(SkiaFonts.SANS_SERIF_TYPEFACE, 22f) private val headerTextColor = Color.makeARGB(200, 0, 0, 0) private val headerText = "Record your own using ${'$'} maestro record YourFlow.yaml" private val headerButtonColor = Color.makeARGB(50, 255, 255, 255) private val headerButtonSize = 20f private val headerButtonGap = 10f private val headerButtonMx = 20f private val footerBgColor = Color.makeARGB(50, 255, 255, 255) private val footerHeight = 60f private val footerFont = Font(SkiaFonts.SANS_SERIF_TYPEFACE, 22f) private val footerTextColor = Color.makeARGB(200, 0, 0, 0) private val footerText = "maestro.mobile.dev" private val terminalBgColor = Color.makeARGB(220, 0, 0, 0) private val terminalContentPadding = 40f private val textClipper = SkiaTextClipper() override fun render( outputWidthPx: Int, outputHeightPx: Int, screen: BufferedImage, text: String ): BufferedImage { return Surface.makeRasterN32Premul(outputWidthPx, outputHeightPx).use { surface -> drawScene(surface.canvas, outputWidthPx.toFloat(), outputHeightPx.toFloat(), screen, text) surface.makeImageSnapshot().toBufferedImage() } } private fun drawScene(canvas: Canvas, outputWidthPx: Float, outputHeightPx: Float, screen: BufferedImage, text: String) { val fullScreenRect = Rect(0f, 0f, outputWidthPx, outputHeightPx) canvas.drawImageRect(backgroundImage, fullScreenRect) val paddedScreenRect = fullScreenRect.inflate(-scenePadding) drawContent(canvas, paddedScreenRect, screen, text) } private fun drawContent(canvas: Canvas, containerRect: Rect, screen: BufferedImage, text: String) { val imageRect = drawDevice(canvas, containerRect, screen) drawTerminal(canvas, containerRect, imageRect, text) } private fun drawDevice(canvas: Canvas, containerRect: Rect, screen: BufferedImage): Rect { val cornerRadius = 20f val deviceImageScale = containerRect.height / screen.height.toFloat() var deviceImageRect = Rect(0f, 0f, screen.width.toFloat(), screen.height.toFloat()).scale(deviceImageScale) deviceImageRect = deviceImageRect.offset(containerRect.right - deviceImageRect.right, containerRect.top) val deviceImageRectRounded = deviceImageRect.toRRect(cornerRadius) canvas.save() canvas.clipRRect(deviceImageRectRounded, true) canvas.drawImageRect(screen.toImage(), deviceImageRect) canvas.restore() canvas.drawRectShadow(deviceImageRectRounded, 0f, 0f, 20f, 0.5f, shadowColor) return deviceImageRect } private fun drawTerminal(canvas: Canvas, containerRect: Rect, imageRect: Rect, text: String) { val terminalRect = Rect(containerRect.left, containerRect.top, imageRect.left - sceneGap, containerRect.bottom) val terminalRectRounded = terminalRect.toRRect(20f) canvas.drawRectShadow(terminalRectRounded, 0f, 0f, 20f, 0.5f, shadowColor) val headerRect = drawHeader(canvas, terminalRect) val footerRect = drawFooter(canvas, terminalRect) drawTerminalContent(canvas, terminalRect, headerRect, footerRect, text) } private fun drawFooter(canvas: Canvas, terminalRect: Rect): Rect { val footerRect = Rect.makeXYWH(terminalRect.left, terminalRect.bottom - footerHeight, terminalRect.width, footerHeight) val headerRectRounded = footerRect.toRRect(0f, 0f, 20f, 20f) canvas.drawRRect(headerRectRounded, Paint().apply { color = footerBgColor }) drawFooterText(canvas, footerRect) return footerRect } private fun drawFooterText(canvas: Canvas, footerRect: Rect) { val paint = Paint().apply { color = footerTextColor } val textRect = footerFont.measureText(footerText, paint) val x = footerRect.left + (footerRect.width - textRect.width) / 2 val y = footerRect.top + footerRect.height / 2 + textRect.height / 3 canvas.drawString(footerText, x, y, footerFont, paint) } private fun drawHeader(canvas: Canvas, terminalRect: Rect): Rect { val headerRect = Rect.makeXYWH(terminalRect.left, terminalRect.top, terminalRect.width, headerHeight) val headerRectRounded = headerRect.toRRect(20f, 20f, 0f, 0f) canvas.drawRRect(headerRectRounded, Paint().apply { color = headerBgColor }) drawHeaderButtons(canvas, headerRect) drawHeaderText(canvas, headerRect) return headerRect } private fun drawHeaderButtons(canvas: Canvas, headerRect: Rect) { var centerX = headerRect.left + headerButtonMx + headerButtonSize / 2 val centerY = headerRect.top + headerRect.height / 2 repeat(3) { canvas.drawCircle(centerX, centerY, headerButtonSize / 2, Paint().apply { color = headerButtonColor }) centerX += headerButtonSize + headerButtonGap } } private fun drawHeaderText(canvas: Canvas, headerRect: Rect) { val paint = Paint().apply { color = headerTextColor } val textRect = headerFont.measureText(headerText, paint) val x = headerRect.left + (headerRect.width - textRect.width) / 2 val y = headerRect.top + headerRect.height / 2 + textRect.height / 3 canvas.drawString(headerText, x, y, headerFont, paint) } private fun drawTerminalContent(canvas: Canvas, terminalRect: Rect, headerRect: Rect, footerRect: Rect, string: String) { val contentRect = Rect.makeLTRB(terminalRect.left, headerRect.bottom, terminalRect.right, footerRect.top) canvas.drawRect(contentRect, Paint().apply { color = terminalBgColor }) val paddedContentRect = Rect.makeLTRB( l = contentRect.left + terminalContentPadding, t = contentRect.top + terminalContentPadding, r = contentRect.right - terminalContentPadding, b = contentRect.bottom - terminalContentPadding / 4f, ) val focusedLineIndex = getFocusedLineIndex(string) val focusedLinePadding = 5 textClipper.renderClippedText(canvas, paddedContentRect, string, focusedLineIndex + focusedLinePadding) } private fun getFocusedLineIndex(text: String): Int { val lines = text.lines() val indexOfFirstPendingLine = lines.indexOfFirst { it.contains("\uD83D\uDD32") } if (indexOfFirstPendingLine != -1) return indexOfFirstPendingLine val indexOfLastCheck = lines.indexOfLast { it.contains("✅") } if (indexOfLastCheck != -1) return indexOfLastCheck return 0 } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt ================================================ package maestro.cli.graphics import org.jetbrains.skia.Canvas import org.jetbrains.skia.Color import org.jetbrains.skia.FontMgr import org.jetbrains.skia.Rect import org.jetbrains.skia.paragraph.FontCollection import org.jetbrains.skia.paragraph.Paragraph import org.jetbrains.skia.paragraph.ParagraphBuilder import org.jetbrains.skia.paragraph.ParagraphStyle import org.jetbrains.skia.paragraph.RectHeightMode import org.jetbrains.skia.paragraph.RectWidthMode import org.jetbrains.skia.paragraph.TextStyle import kotlin.math.min class SkiaTextClipper { private val terminalTextStyle = TextStyle().apply { fontFamilies = SkiaFonts.MONOSPACE_FONT_FAMILIES.toTypedArray() fontSize = 24f color = Color.WHITE } fun renderClippedText(canvas: Canvas, rect: Rect, text: String, focusedLine: Int) { val p = createParagraph(text, rect.width) val focusedLineRange = getRangeForLine(text, focusedLine) val focusedLineBottom = p.getRectsForRange( start = focusedLineRange.first, end = focusedLineRange.second, rectHeightMode = RectHeightMode.MAX, rectWidthMode = RectWidthMode.MAX ).maxOf { it.rect.bottom } val offsetY = min(0f, rect.height - focusedLineBottom) canvas.save() canvas.clipRect(rect) p.paint(canvas, rect.left, rect.top + offsetY) canvas.restore() } private fun getRangeForLine(text: String, lineIndex: Int): Pair { var start = 0 var end = 0 var currentLine = 0 while (currentLine <= lineIndex) { start = end end = text.indexOf('\n', start + 1) if (end == -1) { end = text.length break } currentLine++ } return Pair(start, end) } private fun createParagraph(text: String, width: Float): Paragraph { val fontCollection = FontCollection().setDefaultFontManager(FontMgr.default) return ParagraphBuilder(ParagraphStyle(), fontCollection) .pushStyle(terminalTextStyle) .addText(text) .build() .apply { layout(width) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/SkiaUtils.kt ================================================ package maestro.cli.graphics import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Canvas import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.FontMgr import org.jetbrains.skia.FontStyle import org.jetbrains.skia.Image import org.jetbrains.skia.ImageInfo import org.jetbrains.skia.RRect import org.jetbrains.skia.Rect import org.jetbrains.skia.Typeface import java.awt.Transparency import java.awt.color.ColorSpace import java.awt.image.BufferedImage import java.awt.image.ComponentColorModel import java.awt.image.DataBuffer import java.awt.image.DataBufferByte import java.awt.image.Raster object SkiaFonts { val SANS_SERIF_FONT_FAMILIES = listOf("Inter", "Roboto", "Arial", "Avenir Next", "Avenir", "Helvetica Neue", "Helvetica", "Arial Nova", "Arimo", "Noto Sans", "Liberation Sans", "DejaVu Sans", "Nimbus Sans", "Clear Sans", "Lato", "Cantarell", "Arimo", "Ubuntu") val MONOSPACE_FONT_FAMILIES = listOf("Cascadia Code", "Source Code Pro", "Menlo", "Consolas", "Monaco", "Liberation Mono", "Ubuntu Mono", "Roboto Mono", "Lucida Console", "Monaco", "Courier New", "Courier") val SANS_SERIF_TYPEFACE: Typeface val MONOSPACE_TYPEFACE: Typeface init { val sansSerifTypeface = FontMgr.default.matchFamiliesStyle(SANS_SERIF_FONT_FAMILIES.toTypedArray(), FontStyle.NORMAL) if (sansSerifTypeface == null) { System.err.println("Failed to find a sans-serif typeface.") } SANS_SERIF_TYPEFACE = sansSerifTypeface ?: Typeface.makeEmpty() val monospaceTypeface = FontMgr.default.matchFamiliesStyle(MONOSPACE_FONT_FAMILIES.toTypedArray(), FontStyle.NORMAL) if (monospaceTypeface == null) { System.err.println("Failed to find a monospace typeface.") } MONOSPACE_TYPEFACE = monospaceTypeface ?: Typeface.makeEmpty() } } // https://stackoverflow.com/a/70852824 fun Image.toBufferedImage(): BufferedImage { val storage = Bitmap() storage.allocPixelsFlags(ImageInfo.makeS32(this.width, this.height, ColorAlphaType.PREMUL), false) Canvas(storage).drawImage(this, 0f, 0f) val bytes = storage.readPixels(storage.imageInfo, (this.width * 4), 0, 0)!! val buffer = DataBufferByte(bytes, bytes.size) val raster = Raster.createInterleavedRaster( buffer, this.width, this.height, this.width * 4, 4, intArrayOf(2, 1, 0, 3), // BGRA order null ) val colorModel = ComponentColorModel( ColorSpace.getInstance(ColorSpace.CS_sRGB), true, false, Transparency.TRANSLUCENT, DataBuffer.TYPE_BYTE ) return BufferedImage(colorModel, raster!!, false, null) } fun Rect.toRRect(radii: Float): RRect { return RRect.makeLTRB(this.left, this.top, this.right, this.bottom, radii) } fun Rect.toRRect(tlRad: Float, trRad: Float, brRad: Float, blRad: Float): RRect { return RRect.makeLTRB(this.left, this.top, this.right, this.bottom, tlRad, trRad, brRad, blRad) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/graphics/VideoRenderer.kt ================================================ package maestro.cli.graphics import maestro.cli.runner.resultview.AnsiResultView import java.io.File interface VideoRenderer { fun render( screenRecording: File, textFrames: List, ) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/insights/TestAnalysisManager.kt ================================================ package maestro.cli.insights import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.github.romankh3.image.comparison.ImageComparisonUtil import maestro.cli.api.ApiClient import maestro.cli.cloud.CloudInteractor import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils import maestro.cli.view.box import java.nio.file.Files import java.nio.file.Path import java.util.stream.Collectors import kotlin.io.path.createDirectories import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText data class AnalysisScreenshot ( val data: ByteArray, val path: Path, ) data class AnalysisLog ( val data: ByteArray, val path: Path, ) data class AnalysisDebugFiles( val screenshots: List, val logs: List, val commands: List, ) class TestAnalysisManager(private val apiUrl: String, private val apiKey: String?) { private val apiClient by lazy { ApiClient(apiUrl) } fun runAnalysis(debugOutputPath: Path): Int { val debugFiles = processDebugFiles(debugOutputPath) if (debugFiles == null) { PrintUtils.warn("No screenshots or debug artifacts found for analysis.") return 0; } return CloudInteractor( client = apiClient, appFileValidator = { null }, workspaceValidator = maestro.orchestra.validation.WorkspaceValidator(), ).analyze( apiKey = apiKey, debugFiles = debugFiles, debugOutputPath = debugOutputPath ) } private fun processDebugFiles(outputPath: Path): AnalysisDebugFiles? { val files = Files.walk(outputPath) .filter(Files::isRegularFile) .collect(Collectors.toList()) if (files.isEmpty()) { return null } return getDebugFiles(files) } private fun getDebugFiles(files: List): AnalysisDebugFiles { val logs = mutableListOf() val commands = mutableListOf() val screenshots = mutableListOf() files.forEach { path -> val data = Files.readAllBytes(path) val fileName = path.fileName.toString().lowercase() when { fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> { screenshots.add(AnalysisScreenshot(data = data, path = path)) } fileName.startsWith("commands") -> { commands.add(AnalysisLog(data = data, path = path)) } fileName == "maestro.log" -> { logs.add(AnalysisLog(data = data, path = path)) } } } val filteredScreenshots = filterSimilarScreenshots(screenshots) return AnalysisDebugFiles( logs = logs, commands = commands, screenshots = filteredScreenshots, ) } private val screenshotsDifferenceThreshold = 5.0 private fun filterSimilarScreenshots( screenshots: List ): List { val uniqueScreenshots = mutableListOf() for (screenshot in screenshots) { val isSimilar = uniqueScreenshots.any { existingScreenshot -> val diffPercent = ImageComparisonUtil.getDifferencePercent( ImageComparisonUtil.readImageFromResources(existingScreenshot.path.toString()), ImageComparisonUtil.readImageFromResources(screenshot.path.toString()) ) diffPercent <= screenshotsDifferenceThreshold } if (!isSimilar) { uniqueScreenshots.add(screenshot) } } return uniqueScreenshots } /** * The Notification system for Test Analysis. * - Uses configuration from $XDG_CONFIG_HOME/maestro/analyze-notification.json. */ companion object AnalysisNotification { private const val MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED = "MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED" private val disabled: Boolean get() = System.getenv(MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED) == "true" private val notificationStatePath: Path = EnvUtils.xdgStateHome().resolve("analyze-notification.json") private val JSON = jacksonObjectMapper().apply { registerModule(JavaTimeModule()) enable(SerializationFeature.INDENT_OUTPUT) } private val shouldNotNotify: Boolean get() { if (CiUtils.getCiProvider() != null) { return true } return disabled || (notificationStatePath.exists() && notificationState.acknowledged) } private val notificationState: AnalysisNotificationState get() = JSON.readValue(notificationStatePath.readText()) fun maybeNotify() { if (shouldNotNotify) return println( listOf( "Try out our new Analyze with Ai feature.\n", "See what's new:", "> https://maestro.mobile.dev/cli/test-suites-and-reports#analyze", "Analyze command:", "$ maestro test flow-file.yaml --analyze | bash\n", "To disable this notification, set $MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED environment variable to \"true\" before running Maestro." ).joinToString("\n").box() ) ack(); } private fun ack() { val state = AnalysisNotificationState( acknowledged = true ) val stateJson = JSON.writeValueAsString(state) notificationStatePath.parent.createDirectories() notificationStatePath.writeText(stateJson + "\n") } } } @JsonIgnoreProperties(ignoreUnknown = true) data class AnalysisNotificationState( val acknowledged: Boolean = false ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/McpServer.kt ================================================ package maestro.cli.mcp import io.ktor.utils.io.streams.* import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.* import kotlinx.io.* import maestro.cli.session.MaestroSessionManager import maestro.debuglog.LogConfig import maestro.cli.mcp.tools.ListDevicesTool import maestro.cli.mcp.tools.StartDeviceTool import maestro.cli.mcp.tools.LaunchAppTool import maestro.cli.mcp.tools.TakeScreenshotTool import maestro.cli.mcp.tools.TapOnTool import maestro.cli.mcp.tools.InputTextTool import maestro.cli.mcp.tools.BackTool import maestro.cli.mcp.tools.StopAppTool import maestro.cli.mcp.tools.RunFlowTool import maestro.cli.mcp.tools.RunFlowFilesTool import maestro.cli.mcp.tools.CheckFlowSyntaxTool import maestro.cli.mcp.tools.InspectViewHierarchyTool import maestro.cli.mcp.tools.CheatSheetTool import maestro.cli.mcp.tools.QueryDocsTool import maestro.cli.util.WorkingDirectory // Main function to run the Maestro MCP server fun runMaestroMcpServer() { // Disable all console logging to prevent interference with JSON-RPC communication LogConfig.configure(logFileName = null, printToConsole = false) val sessionManager = MaestroSessionManager // Create the MCP Server instance with Maestro implementation val server = Server( Implementation( name = "maestro", version = "1.0.0" ), ServerOptions( capabilities = ServerCapabilities( tools = ServerCapabilities.Tools(listChanged = true) ) ) ) // Register tools server.addTools(listOf( ListDevicesTool.create(), StartDeviceTool.create(), LaunchAppTool.create(sessionManager), TakeScreenshotTool.create(sessionManager), TapOnTool.create(sessionManager), InputTextTool.create(sessionManager), BackTool.create(sessionManager), StopAppTool.create(sessionManager), RunFlowTool.create(sessionManager), RunFlowFilesTool.create(sessionManager), CheckFlowSyntaxTool.create(), InspectViewHierarchyTool.create(sessionManager), CheatSheetTool.create(), QueryDocsTool.create() )) // Create a transport using standard IO for server communication val transport = StdioServerTransport( System.`in`.asSource().buffered(), System.out.asSink().buffered() ) System.err.println("MCP Server: Started. Waiting for messages. Working directory: ${WorkingDirectory.baseDir}") runBlocking { server.connect(transport) val done = Job() server.onClose { done.complete() } done.join() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/README.md ================================================ # Maestro MCP Server ## Overview The Maestro MCP (Model Context Protocol) server enables LLM-driven automation and orchestration of Maestro commands and device management. It serves two primary functions for calling LLMs: - enables LLMs to directly control and interact with devices using Maestro device capabilities - enables LLMs to write, validate, and run Maestro code (flows) The MCP server is designed to be extensible, maintainable, and easy to run as part of the Maestro CLI. It supports real-time device management, app automation, and more, all via a standardized protocol. ## Features - Exposes Maestro device and automation commands as MCP tools - Supports listing, launching, and interacting with devices - Supports running flow yaml or files and checking the flow file syntax - Easily extensible: add new tools with minimal boilerplate - Includes a test script and config for automated validation ## Running the MCP Server To use the MCP server as an end user, after following the maestro install instructions run: ``` maestro mcp ``` This launches the MCP server via the Maestro CLI, exposing Maestro tools over STDIO for LLM agents and other clients. ## Developing ## Extending the MCP Server To add a new tool: 1. Create a new file in `maestro-cli/src/main/java/maestro/cli/mcp/tools/` following the same patterns as the other tools. 2. Add your tool to the `addTools` call in `McpServer.kt` 3. Build the CLI with `./gradlew :maestro-cli:installDist` 4. Test your tool by running `./maestro-cli/src/test/mcp/test-single-mcp-tool.sh` with appropriate args for your tool ## Evals testing When testing a Maestro MCP tool, it's important to test not only that it works correctly but that LLMs can call it correctly and use the output appropriately. This happens less frequently than is expected. Make sure to add relevant test cases to our evals framework in `./maestro-cli/src/test/mcp/maestro-evals.yaml`, and then run the eval test suite with: ``` ANTHROPIC_API_KEY= ./maestro-cli/src/test/mcp/run-mcp-server-evals.sh ``` ## Implementation Notes & Rationale ### Using forked version of official kotlin MCP SDK The [official MCP Kotlin SDK](https://github.com/modelcontextprotocol/kotlin-sdk) can't be used directly because it requires Java 21 and Kotlin 2.x, while Maestro is built on Java 8 and Kotlin 1.8.x for broad compatibility. However, we want to be able to benefit from features added to the SDK since the MCP spec is changing rapidly. So we created a fork that "downgrades" the reference SDK to Java 8 and Kotlin 1.8.22. ### Why Integrate MCP Server Directly Into `maestro-cli`? - **Dependency Management:** The MCP server needs access to abstractions like `MaestroSessionManager` and other CLI internals. Placing it in a separate module (e.g., `maestro-mcp`) would create a circular dependency between `maestro-cli` and the new module. - **Simplicity:** Keeping all MCP logic within `maestro-cli` avoids complex build configurations and makes the integration easier to maintain and review. - **Extensibility:** This approach allows new tools to be added with minimal boilerplate and direct access to CLI features. ### Potential Future Improvements - **Shared Abstractions:** If more MCP-related code or other integrations are needed, consider extracting shared abstractions (e.g., session management, tool interfaces) into a `common` or `core` module. This would allow for a clean separation and potentially enable a standalone `maestro-mcp` module. - **Streamable HTTP:** This MCP server currently only uses STDIO for communication. ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/BackTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.BackPressCommand import maestro.orchestra.Orchestra import maestro.orchestra.MaestroCommand import kotlinx.coroutines.runBlocking object BackTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "back", description = "Press the back button on the device", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to press back on") } }, required = listOf("device_id") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content if (deviceId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("device_id is required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val command = BackPressCommand( label = null, optional = false ) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(listOf(MaestroCommand(command = command))) } buildJsonObject { put("success", true) put("device_id", deviceId) put("message", "Back button pressed successfully") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to press back: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/CheatSheetTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.auth.ApiKey import maestro.utils.HttpClient import okhttp3.Request import kotlin.time.Duration.Companion.minutes object CheatSheetTool { fun create(): RegisteredTool { return RegisteredTool( Tool( name = "cheat_sheet", description = "Get the Maestro cheat sheet with common commands and syntax examples. " + "Returns comprehensive documentation on Maestro flow syntax, commands, and best practices.", inputSchema = Tool.Input( properties = buildJsonObject {}, required = emptyList() ) ) ) { _ -> try { val apiKey = ApiKey.getToken() if (apiKey.isNullOrBlank()) { return@RegisteredTool CallToolResult( content = listOf(TextContent("MAESTRO_CLOUD_API_KEY environment variable is required")), isError = true ) } val client = HttpClient.build( name = "CheatSheetTool", readTimeout = 2.minutes ) // Make GET request to cheat sheet endpoint val httpRequest = Request.Builder() .url("https://api.copilot.mobile.dev/v2/bot/maestro-cheat-sheet") .header("Authorization", "Bearer $apiKey") .get() .build() val response = client.newCall(httpRequest).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown error" return@RegisteredTool CallToolResult( content = listOf(TextContent("Failed to get cheat sheet (${response.code}): $errorMessage")), isError = true ) } val cheatSheetContent = response.body?.string() ?: "" CallToolResult(content = listOf(TextContent(cheatSheetContent))) } } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to get cheat sheet: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/CheckFlowSyntaxTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.orchestra.yaml.YamlCommandReader object CheckFlowSyntaxTool { fun create(): RegisteredTool { return RegisteredTool( Tool( name = "check_flow_syntax", description = "Validates the syntax of a block of Maestro code. Valid maestro code must be well-formatted YAML.", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("flow_yaml") { put("type", "string") put("description", "YAML-formatted Maestro flow content to validate") } }, required = listOf("flow_yaml") ) ) ) { request -> try { val flowYaml = request.arguments["flow_yaml"]?.jsonPrimitive?.content if (flowYaml == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("flow_yaml is required")), isError = true ) } val result = try { YamlCommandReader.checkSyntax(flowYaml) buildJsonObject { put("valid", true) put("message", "Flow syntax is valid") }.toString() } catch (e: Exception) { buildJsonObject { put("valid", false) put("error", e.message ?: "Unknown parsing error") put("message", "Syntax check failed") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to check flow syntax: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/InputTextTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.InputTextCommand import maestro.orchestra.Orchestra import maestro.orchestra.MaestroCommand import kotlinx.coroutines.runBlocking object InputTextTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "input_text", description = "Input text into the currently focused text field", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to input text on") } putJsonObject("text") { put("type", "string") put("description", "The text to input") } }, required = listOf("device_id", "text") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val text = request.arguments["text"]?.jsonPrimitive?.content if (deviceId == null || text == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Both device_id and text are required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val command = InputTextCommand( text = text, label = null, optional = false ) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(listOf(MaestroCommand(command = command))) } buildJsonObject { put("success", true) put("device_id", deviceId) put("text", text) put("message", "Text input successful") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to input text: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/InspectViewHierarchyTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.TreeNode import kotlinx.coroutines.runBlocking object InspectViewHierarchyTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "inspect_view_hierarchy", description = "Get the nested view hierarchy of the current screen in CSV format. Returns UI elements " + "with bounds coordinates for interaction. Use this to understand screen layout, find specific elements " + "by text/id, or locate interactive components. Elements include bounds (x,y,width,height), text content, " + "resource IDs, and interaction states (clickable, enabled, checked).", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to get hierarchy from") } }, required = listOf("device_id") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content if (deviceId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("device_id is required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val maestro = session.maestro val viewHierarchy = maestro.viewHierarchy() val tree = viewHierarchy.root // Return CSV format (original format for compatibility) ViewHierarchyFormatters.extractCsvOutput(tree) } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to inspect UI: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/LaunchAppTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.LaunchAppCommand import maestro.orchestra.Orchestra import maestro.orchestra.MaestroCommand import kotlinx.coroutines.runBlocking object LaunchAppTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "launch_app", description = "Launch an application on the connected device", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to launch the app on") } putJsonObject("appId") { put("type", "string") put("description", "Bundle ID or app ID to launch") } }, required = listOf("device_id", "appId") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val appId = request.arguments["appId"]?.jsonPrimitive?.content if (deviceId == null || appId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Both device_id and appId are required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val command = LaunchAppCommand( appId = appId, clearState = null, clearKeychain = null, stopApp = null, permissions = null, launchArguments = null, label = null, optional = false ) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(listOf(MaestroCommand(command = command))) } buildJsonObject { put("success", true) put("device_id", deviceId) put("app_id", appId) put("message", "App launched successfully") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to launch app: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/ListDevicesTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.device.DeviceService object ListDevicesTool { fun create(): RegisteredTool { return RegisteredTool( Tool( name = "list_devices", description = "List all available devices that can be launched for automation.", inputSchema = Tool.Input( properties = buildJsonObject { }, required = emptyList() ) ) ) { _ -> try { val availableDevices = DeviceService.listAvailableForLaunchDevices(includeWeb = true) val connectedDevices = DeviceService.listConnectedDevices() val allDevices = buildJsonArray { // Add connected devices connectedDevices.forEach { device -> addJsonObject { put("device_id", device.instanceId) put("name", device.description) put("platform", device.platform.name.lowercase()) put("type", device.deviceType.name.lowercase()) put("connected", true) } } // Add available devices that aren't already connected availableDevices.forEach { device -> val alreadyConnected = connectedDevices.any { it.instanceId == device.modelId } if (!alreadyConnected) { addJsonObject { put("device_id", device.modelId) put("name", device.description) put("platform", device.platform.name.lowercase()) put("type", device.deviceType.name.lowercase()) put("connected", false) } } } } val result = buildJsonObject { put("devices", allDevices) } CallToolResult(content = listOf(TextContent(result.toString()))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to list devices: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/QueryDocsTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.auth.ApiKey import maestro.utils.HttpClient import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import kotlin.time.Duration.Companion.minutes object QueryDocsTool { fun create(): RegisteredTool { return RegisteredTool( Tool( name = "query_docs", description = "Query the Maestro documentation for specific information. " + "Ask questions about Maestro features, commands, best practices, and troubleshooting. " + "Returns relevant documentation content and examples.", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("question") { put("type", "string") put("description", "The question to ask about Maestro documentation") } }, required = listOf("question") ) ) ) { request -> try { val question = request.arguments["question"]?.jsonPrimitive?.content if (question.isNullOrBlank()) { return@RegisteredTool CallToolResult( content = listOf(TextContent("question parameter is required")), isError = true ) } val apiKey = ApiKey.getToken() if (apiKey.isNullOrBlank()) { return@RegisteredTool CallToolResult( content = listOf(TextContent("MAESTRO_CLOUD_API_KEY environment variable is required")), isError = true ) } val client = HttpClient.build( name = "QueryDocsTool", readTimeout = 2.minutes ) // Create JSON request body val requestBody = buildJsonObject { put("question", question) }.toString() // Make POST request to query docs endpoint val httpRequest = Request.Builder() .url("https://api.copilot.mobile.dev/v2/bot/query-docs") .header("Authorization", "Bearer $apiKey") .header("Content-Type", "application/json") .post(requestBody.toRequestBody("application/json".toMediaType())) .build() val response = client.newCall(httpRequest).execute() response.use { if (!response.isSuccessful) { val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: "Unknown error" return@RegisteredTool CallToolResult( content = listOf(TextContent("Failed to query docs (${response.code}): $errorMessage")), isError = true ) } val responseBody = response.body?.string() ?: "" try { val jsonResponse = Json.parseToJsonElement(responseBody).jsonObject val answer = jsonResponse["answer"]?.jsonPrimitive?.content ?: responseBody CallToolResult(content = listOf(TextContent(answer))) } catch (e: Exception) { // If JSON parsing fails, return the raw response CallToolResult(content = listOf(TextContent(responseBody))) } } } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to query docs: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/RunFlowFilesTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.Orchestra import maestro.orchestra.yaml.YamlCommandReader import maestro.orchestra.util.Env.withEnv import maestro.orchestra.util.Env.withInjectedShellEnvVars import maestro.orchestra.util.Env.withDefaultEnvVars import kotlinx.coroutines.runBlocking import java.io.File import java.nio.file.Paths import maestro.cli.util.WorkingDirectory object RunFlowFilesTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "run_flow_files", description = "Run one or more full Maestro test files. If no device is running, you'll need to start a device first. If the command fails using a relative path, try using an absolute path.", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to run the flows on") } putJsonObject("flow_files") { put("type", "string") put("description", "Comma-separated file paths to YAML flow files to execute (e.g., 'flow1.yaml,flow2.yaml')") } putJsonObject("env") { put("type", "object") put("description", "Optional environment variables to inject into the flows (e.g., {\"APP_ID\": \"com.example.app\", \"LANGUAGE\": \"tr\", \"COUNTRY\": \"TR\"})") putJsonObject("additionalProperties") { put("type", "string") } } }, required = listOf("device_id", "flow_files") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val flowFilesString = request.arguments["flow_files"]?.jsonPrimitive?.content val envParam = request.arguments["env"]?.jsonObject if (deviceId == null || flowFilesString == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Both device_id and flow_files are required")), isError = true ) } val flowFiles = flowFilesString.split(",").map { it.trim() } if (flowFiles.isEmpty()) { return@RegisteredTool CallToolResult( content = listOf(TextContent("At least one flow file must be provided")), isError = true ) } // Parse environment variables from JSON object val env = envParam?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap() // Resolve all flow files to File objects once val resolvedFiles = flowFiles.map { WorkingDirectory.resolve(it) } // Validate all files exist before executing val missingFiles = resolvedFiles.filter { !it.exists() } if (missingFiles.isNotEmpty()) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Files not found: ${missingFiles.joinToString(", ") { it.absolutePath }}")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val orchestra = Orchestra(session.maestro) val results = mutableListOf>() var totalCommands = 0 for (fileObj in resolvedFiles) { try { val commands = YamlCommandReader.readCommands(fileObj.toPath()) val finalEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(fileObj, deviceId) val commandsWithEnv = commands.withEnv(finalEnv) runBlocking { orchestra.runFlow(commandsWithEnv) } results.add(mapOf( "file" to fileObj.absolutePath, "success" to true, "commands_executed" to commands.size, "message" to "Flow executed successfully" )) totalCommands += commands.size } catch (e: Exception) { results.add(mapOf( "file" to fileObj.absolutePath, "success" to false, "error" to (e.message ?: "Unknown error"), "message" to "Flow execution failed" )) } } val finalEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(deviceId = deviceId) buildJsonObject { put("success", results.all { (it["success"] as Boolean) }) put("device_id", deviceId) put("total_files", flowFiles.size) put("total_commands_executed", totalCommands) putJsonArray("results") { results.forEach { result -> addJsonObject { result.forEach { (key, value) -> when (value) { is String -> put(key, value) is Boolean -> put(key, value) is Int -> put(key, value) else -> put(key, value.toString()) } } } } } if (finalEnv.isNotEmpty()) { putJsonObject("env_vars") { finalEnv.forEach { (key, value) -> put(key, value) } } } put("message", if (results.all { (it["success"] as Boolean) }) "All flows executed successfully" else "Some flows failed to execute") }.toString() } // Check if any flows failed and return isError accordingly val anyFlowsFailed = result.contains("\"success\":false") CallToolResult( content = listOf(TextContent(result)), isError = anyFlowsFailed ) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to run flow files: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/RunFlowTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.Orchestra import maestro.orchestra.yaml.YamlCommandReader import maestro.orchestra.util.Env.withEnv import maestro.orchestra.util.Env.withInjectedShellEnvVars import maestro.orchestra.util.Env.withDefaultEnvVars import kotlinx.coroutines.runBlocking import java.io.File import java.nio.file.Files object RunFlowTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "run_flow", description = """ Use this when interacting with a device and running adhoc commands, preferably one at a time. Whenever you're exploring an app, testing out commands or debugging, prefer using this tool over creating temp files and using run_flow_files. Run a set of Maestro commands (one or more). This can be a full maestro script (including headers), a set of commands (one per line) or simply a single command (eg '- tapOn: 123'). If this fails due to no device running, please ask the user to start a device! If you don't have an up-to-date view hierarchy or screenshot on which to execute the commands, please call inspect_view_hierarchy first, instead of blindly guessing. *** You don't need to call check_syntax before executing this, as syntax will be checked as part of the execution flow. *** Use the `inspect_view_hierarchy` tool to retrieve the current view hierarchy and use it to execute commands on the device. Use the `cheat_sheet` tool to retrieve a summary of Maestro's flow syntax before using any of the other tools. Examples of valid inputs: ``` - tapOn: 123 ``` ``` appId: any --- - tapOn: 123 ``` ``` appId: any # other headers here --- - tapOn: 456 - scroll # other commands here ``` """.trimIndent(), inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to run the flow on") } putJsonObject("flow_yaml") { put("type", "string") put("description", "YAML-formatted Maestro flow content to execute") } putJsonObject("env") { put("type", "object") put("description", "Optional environment variables to inject into the flow (e.g., {\"APP_ID\": \"com.example.app\", \"LANGUAGE\": \"en\"})") putJsonObject("additionalProperties") { put("type", "string") } } }, required = listOf("device_id", "flow_yaml") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val flowYaml = request.arguments["flow_yaml"]?.jsonPrimitive?.content val envParam = request.arguments["env"]?.jsonObject if (deviceId == null || flowYaml == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Both device_id and flow_yaml are required")), isError = true ) } // Parse environment variables from JSON object val env = envParam?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap() val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> // Create a temporary file with the YAML content val tempFile = Files.createTempFile("maestro-flow", ".yaml").toFile() try { tempFile.writeText(flowYaml) // Parse and execute the flow with environment variables val commands = YamlCommandReader.readCommands(tempFile.toPath()) val finalEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(tempFile, deviceId) val commandsWithEnv = commands.withEnv(finalEnv) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(commandsWithEnv) } buildJsonObject { put("success", true) put("device_id", deviceId) put("commands_executed", commands.size) put("message", "Flow executed successfully") if (finalEnv.isNotEmpty()) { putJsonObject("env_vars") { finalEnv.forEach { (key, value) -> put(key, value) } } } }.toString() } finally { // Clean up the temporary file tempFile.delete() } } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to run flow: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/StartDeviceTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.device.DeviceService import maestro.device.Platform object StartDeviceTool { fun create(): RegisteredTool { return RegisteredTool( Tool( name = "start_device", description = "Start a device (simulator/emulator) and return its device ID. " + "You must provide either a device_id (from list_devices) or a platform (ios or android). " + "If device_id is provided, starts that device. If platform is provided, starts any available device for that platform. " + "If neither is provided, defaults to platform = ios.", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "ID of the device to start (from list_devices). Optional.") } putJsonObject("platform") { put("type", "string") put("description", "Platform to start (ios or android). Optional. Default: ios.") } }, required = emptyList() ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val platformStr = request.arguments["platform"]?.jsonPrimitive?.content ?: "ios" // Get all connected and available devices val availableDevices = DeviceService.listAvailableForLaunchDevices(includeWeb = true) val connectedDevices = DeviceService.listConnectedDevices() // Helper to build result fun buildResult(device: maestro.device.Device.Connected, alreadyRunning: Boolean): String { return buildJsonObject { put("device_id", device.instanceId) put("name", device.description) put("platform", device.platform.name.lowercase()) put("type", device.deviceType.name.lowercase()) put("already_running", alreadyRunning) }.toString() } if (deviceId != null) { // Check for a connected device with this instanceId val connected = connectedDevices.find { it.instanceId == deviceId } if (connected != null) { return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connected, true)))) } // Check for an available device with this modelId val available = availableDevices.find { it.modelId == deviceId } if (available != null) { val connectedDevice = DeviceService.startDevice( device = available, driverHostPort = null ) return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connectedDevice, false)))) } return@RegisteredTool CallToolResult( content = listOf(TextContent("No device found with device_id: $deviceId")), isError = true ) } // No device_id provided: use platform val platform = Platform.fromString(platformStr) // Check for a connected device matching the platform val connected = connectedDevices.find { it.platform == platform } if (connected != null) { return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connected, true)))) } // Check for an available device matching the platform val available = availableDevices.find { it.platform == platform } if (available != null) { val connectedDevice = DeviceService.startDevice( device = available, driverHostPort = null ) return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connectedDevice, false)))) } return@RegisteredTool CallToolResult( content = listOf(TextContent("No available or connected device found for platform: $platformStr")), isError = true ) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to start device: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/StopAppTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.StopAppCommand import maestro.orchestra.Orchestra import maestro.orchestra.MaestroCommand import kotlinx.coroutines.runBlocking object StopAppTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "stop_app", description = "Stop an application on the connected device", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to stop the app on") } putJsonObject("appId") { put("type", "string") put("description", "Bundle ID or app ID to stop") } }, required = listOf("device_id", "appId") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val appId = request.arguments["appId"]?.jsonPrimitive?.content if (deviceId == null || appId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Both device_id and appId are required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val command = StopAppCommand( appId = appId, label = null, optional = false ) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(listOf(MaestroCommand(command = command))) } buildJsonObject { put("success", true) put("device_id", deviceId) put("app_id", appId) put("message", "App stopped successfully") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to stop app: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/TakeScreenshotTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import okio.Buffer import java.util.Base64 import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import javax.imageio.ImageIO object TakeScreenshotTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "take_screenshot", description = "Take a screenshot of the current device screen", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to take a screenshot from") } }, required = listOf("device_id") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content if (deviceId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("device_id is required")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> val buffer = Buffer() session.maestro.takeScreenshot(buffer, true) val pngBytes = buffer.readByteArray() // Convert PNG to JPEG val pngImage = ImageIO.read(ByteArrayInputStream(pngBytes)) val jpegOutput = ByteArrayOutputStream() ImageIO.write(pngImage, "JPEG", jpegOutput) val jpegBytes = jpegOutput.toByteArray() val base64 = Base64.getEncoder().encodeToString(jpegBytes) base64 } val imageContent = ImageContent( data = result, mimeType = "image/jpeg" ) CallToolResult(content = listOf(imageContent)) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to take screenshot: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/TapOnTool.kt ================================================ package maestro.cli.mcp.tools import io.modelcontextprotocol.kotlin.sdk.* import io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool import kotlinx.serialization.json.* import maestro.cli.session.MaestroSessionManager import maestro.orchestra.ElementSelector import maestro.orchestra.TapOnElementCommand import maestro.orchestra.Orchestra import maestro.orchestra.MaestroCommand import kotlinx.coroutines.runBlocking object TapOnTool { fun create(sessionManager: MaestroSessionManager): RegisteredTool { return RegisteredTool( Tool( name = "tap_on", description = "Tap on a UI element by selector or description", inputSchema = Tool.Input( properties = buildJsonObject { putJsonObject("device_id") { put("type", "string") put("description", "The ID of the device to tap on") } putJsonObject("text") { put("type", "string") put("description", "Text content to match (from 'text' field in inspect_ui output)") } putJsonObject("id") { put("type", "string") put("description", "Element ID to match (from 'id' field in inspect_ui output)") } putJsonObject("index") { put("type", "integer") put("description", "0-based index if multiple elements match the same criteria") } putJsonObject("use_fuzzy_matching") { put("type", "boolean") put("description", "Whether to use fuzzy/partial text matching (true, default) or exact regex matching (false)") } putJsonObject("enabled") { put("type", "boolean") put("description", "If true, only match enabled elements. If false, only match disabled elements. Omit this field to match regardless of enabled state.") } putJsonObject("checked") { put("type", "boolean") put("description", "If true, only match checked elements. If false, only match unchecked elements. Omit this field to match regardless of checked state.") } putJsonObject("focused") { put("type", "boolean") put("description", "If true, only match focused elements. If false, only match unfocused elements. Omit this field to match regardless of focus state.") } putJsonObject("selected") { put("type", "boolean") put("description", "If true, only match selected elements. If false, only match unselected elements. Omit this field to match regardless of selection state.") } }, required = listOf("device_id") ) ) ) { request -> try { val deviceId = request.arguments["device_id"]?.jsonPrimitive?.content val text = request.arguments["text"]?.jsonPrimitive?.content val id = request.arguments["id"]?.jsonPrimitive?.content val index = request.arguments["index"]?.jsonPrimitive?.intOrNull val useFuzzyMatching = request.arguments["use_fuzzy_matching"]?.jsonPrimitive?.booleanOrNull ?: true val enabled = request.arguments["enabled"]?.jsonPrimitive?.booleanOrNull val checked = request.arguments["checked"]?.jsonPrimitive?.booleanOrNull val focused = request.arguments["focused"]?.jsonPrimitive?.booleanOrNull val selected = request.arguments["selected"]?.jsonPrimitive?.booleanOrNull if (deviceId == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("device_id is required")), isError = true ) } // Validate that at least one selector is provided if (text == null && id == null) { return@RegisteredTool CallToolResult( content = listOf(TextContent("Either 'text' or 'id' parameter must be provided")), isError = true ) } val result = sessionManager.newSession( host = null, port = null, driverHostPort = null, deviceId = deviceId, platform = null ) { session -> // Escape special regex characters to prevent regex injection issues fun escapeRegex(input: String): String { return input.replace(Regex("[()\\[\\]{}+*?^$|.\\\\]")) { "\\${it.value}" } } val elementSelector = ElementSelector( textRegex = if (useFuzzyMatching && text != null) ".*${escapeRegex(text)}.*" else text, idRegex = if (useFuzzyMatching && id != null) ".*${escapeRegex(id)}.*" else id, index = index?.toString(), enabled = enabled, checked = checked, focused = focused, selected = selected ) val command = TapOnElementCommand( selector = elementSelector, retryIfNoChange = true, waitUntilVisible = true ) val orchestra = Orchestra(session.maestro) runBlocking { orchestra.runFlow(listOf(MaestroCommand(command = command))) } buildJsonObject { put("success", true) put("device_id", deviceId) put("message", "Tap executed successfully") }.toString() } CallToolResult(content = listOf(TextContent(result))) } catch (e: Exception) { CallToolResult( content = listOf(TextContent("Failed to tap element: ${e.message}")), isError = true ) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/mcp/tools/ViewHierarchyFormatters.kt ================================================ package maestro.cli.mcp.tools import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.dataformat.yaml.YAMLMapper import maestro.TreeNode /** * Various formatters for view hierarchy output. * Preserves different approaches for potential future use. */ object ViewHierarchyFormatters { /** * Original CSV format - matches PrintHierarchyCommand.kt exactly */ fun extractCsvOutput(node: TreeNode?): String { if (node == null) return "element_num,depth,bounds,attributes,parent_num\n" val csv = StringBuilder() csv.appendLine("element_num,depth,bounds,attributes,parent_num") val nodeToId = mutableMapOf() // Assign IDs to each node var counter = 0 node.aggregate().forEach { treeNode -> nodeToId[treeNode] = counter++ } // Process tree recursively to generate CSV processTreeToCSV(node, 0, null, nodeToId, csv) return csv.toString() } private fun processTreeToCSV( node: TreeNode?, depth: Int, parentId: Int?, nodeToId: Map, csv: StringBuilder ) { if (node == null) return val nodeId = nodeToId[node] ?: return // Extract bounds as separate column val bounds = node.attributes["bounds"] ?: "" val quotedBounds = if (bounds.isNotEmpty()) "\"$bounds\"" else "" // Build attributes string (exclude bounds since it's now a separate column) val attributesList = mutableListOf() // Add normal attributes (skip boolean properties and bounds that are handled separately) val excludedProperties = setOf("clickable", "enabled", "focused", "checked", "selected", "bounds") node.attributes.forEach { (key, value) -> if (value.isNotEmpty() && value != "false" && !excludedProperties.contains(key)) { attributesList.add("$key=$value") } } // Add boolean properties if true if (node.clickable == true) attributesList.add("clickable=true") if (node.enabled == true) attributesList.add("enabled=true") if (node.focused == true) attributesList.add("focused=true") if (node.checked == true) attributesList.add("checked=true") if (node.selected == true) attributesList.add("selected=true") // Join all attributes with "; " val attributesString = attributesList.joinToString("; ") // Escape quotes in the attributes string if needed val escapedAttributes = attributesString.replace("\"", "\"\"") // Add this node to CSV csv.append("$nodeId,$depth,$quotedBounds,\"$escapedAttributes\",${parentId ?: ""}\n") // Process children node.children.forEach { child -> processTreeToCSV(child, depth + 1, nodeId, nodeToId, csv) } } /** * Compact CSV format with filtering and abbreviated columns * * Example output: * id,depth,bounds,text,resource_id,accessibility,hint,class,value,scrollable,clickable,enabled,focused,selected,checked,parent_id * 0,0,"[9,22][402,874]",,,"Demo App",,,,,1,,,,, * 1,1,"[63,93][347,128]",,,"Flutter Demo Home Page",,,,,1,,,,,0 * 2,1,"[131,139][279,187]",,,"Defects Test",,,,,1,,,,,0 * 3,1,"[330,768][386,824]",,"fabAddIcon","Increment",,,,,1,,,,,0 */ fun extractCompactCsvOutput(node: TreeNode, platform: String): String { val csv = StringBuilder() csv.appendLine("id,depth,bounds,text,resource_id,accessibility,hint,class,value,scrollable,clickable,enabled,focused,selected,checked,parent_id") val compactElements = compactTreeData(node, platform) val flatElements = mutableListOf, Int>>() // element, depth // Flatten the nested structure while preserving depth fun flattenElements(elements: List>, depth: Int) { elements.forEach { element -> flatElements.add(element to depth) @Suppress("UNCHECKED_CAST") val children = element["c"] as? List> if (children != null) { flattenElements(children, depth + 1) } } } flattenElements(compactElements, 0) // Build CSV rows flatElements.forEachIndexed { index, (element, depth) -> val bounds = element["b"] as? String ?: "" val text = element["txt"] as? String ?: "" val resourceId = element["rid"] as? String ?: "" val accessibility = element["a11y"] as? String ?: "" val hint = element["hint"] as? String ?: "" val className = element["cls"] as? String ?: "" val value = element["val"] as? String ?: "" val scrollable = if (element["scroll"] == true) "1" else "" val clickable = if (element["clickable"] == true) "1" else "" val enabled = if (element["enabled"] == false) "0" else "1" val focused = if (element["focused"] == true) "1" else "" val selected = if (element["selected"] == true) "1" else "" val checked = if (element["checked"] == true) "1" else "" // Find parent ID (previous element with lower depth) var parentId = "" for (i in index - 1 downTo 0) { if (flatElements[i].second < depth) { parentId = i.toString() break } } // Quote strings only if they have content val quotedBounds = quoteIfNotEmpty(bounds) val quotedText = quoteIfNotEmpty(text) val quotedA11y = quoteIfNotEmpty(accessibility) val quotedHint = quoteIfNotEmpty(hint) val quotedClass = quoteIfNotEmpty(className) val quotedValue = quoteIfNotEmpty(value) val quotedRid = quoteIfNotEmpty(resourceId) csv.append("$index,$depth,$quotedBounds,$quotedText,$quotedRid,$quotedA11y,$quotedHint,$quotedClass,$quotedValue,$scrollable,$clickable,$enabled,$focused,$selected,$checked,$parentId\n") } return csv.toString() } private fun quoteIfNotEmpty(value: String): String { return if (value.isNotEmpty()) { "\"${value.replace("\"", "\"\"")}\"" } else { "" } } /** * Compact JSON format with filtering and abbreviated keys */ fun extractCompactJsonOutput(node: TreeNode, platform: String): String { val compactData = createCompactWithSchema(node, platform) return jacksonObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL) .writeValueAsString(compactData) } /** * Compact YAML format with filtering and abbreviated keys */ fun extractCompactYamlOutput(node: TreeNode, platform: String): String { val yamlMapper = YAMLMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) val result = StringBuilder() // Get the full data structure val compactData = createCompactWithSchema(node, platform) // Add document separator result.appendLine("---") // Serialize and add ui_schema section with comment result.appendLine("# Schema definitions - explains abbreviations and default values used in elements") val schemaYaml = yamlMapper.writeValueAsString(mapOf("ui_schema" to compactData["ui_schema"])) .removePrefix("---\n") // Remove extra document separator result.append(schemaYaml) // Serialize and add elements section with comment result.appendLine("# UI Elements - the actual view hierarchy with abbreviated attribute names") val elementsYaml = yamlMapper.writeValueAsString(mapOf("elements" to compactData["elements"])) .removePrefix("---\n") // Remove extra document separator result.append(elementsYaml) return result.toString() } private fun createCompactWithSchema(node: TreeNode, platform: String): Map { val result = mutableMapOf() // Add platform-specific schema result["ui_schema"] = createPlatformSchema(platform) // Convert the tree to compact structure using existing logic result["elements"] = compactTreeData(node, platform) return result } private fun createPlatformSchema(platform: String): Map { return when (platform) { "android" -> createAndroidSchema() "ios" -> createIOSSchema() else -> createIOSSchema() // Default to iOS if unknown } } private fun createAndroidSchema(): Map { return mapOf( "platform" to "android", "abbreviations" to mapOf( "b" to "bounds", "txt" to "text", "rid" to "resource-id", "a11y" to "content-desc", "hint" to "hintText", "cls" to "class", "scroll" to "scrollable", "c" to "children" ), "defaults" to mapOf( "enabled" to true, "clickable" to false, "focused" to false, "selected" to false, "checked" to false, "scrollable" to false, "txt" to "", "hint" to "", "rid" to "", "a11y" to "", "cls" to "" ) ) } private fun createIOSSchema(): Map { return mapOf( "platform" to "ios", "abbreviations" to mapOf( "b" to "bounds", "txt" to "text", "rid" to "resource-id", "a11y" to "accessibilityText", "hint" to "hintText", "val" to "value", "c" to "children" ), "defaults" to mapOf( "enabled" to true, "focused" to false, "selected" to false, "checked" to false, "txt" to "", "hint" to "", "rid" to "", "val" to "", "a11y" to "" ) ) } /** * Recursively processes the UI tree to create a compact representation by: * 1. Filtering out meaningless nodes (zero-size, empty containers) * 2. Converting remaining nodes to abbreviated attribute maps * 3. Flattening the hierarchy by removing wrapper containers */ private fun compactTreeData(node: TreeNode, platform: String): List> { // Skip zero-size elements (invisible/collapsed elements serve no automation purpose) if (hasZeroSize(node)) { return node.children.flatMap { compactTreeData(it, platform) } } // Skip nodes with no meaningful content (empty containers that only provide structure) if (!hasNonDefaultValues(node, platform)) { return node.children.flatMap { compactTreeData(it, platform) } } // Process this node - convert to compact representation with abbreviated attributes val element = convertToCompactNode(node).toMutableMap() // Recursively process children with same filtering rules val children = node.children.flatMap { compactTreeData(it, platform) } // Add children array only if there are meaningful child elements if (children.isNotEmpty()) { element["c"] = children } return listOf(element) } private fun hasZeroSize(node: TreeNode): Boolean { val bounds = node.attributes["bounds"] ?: return false val boundsPattern = Regex("\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]") val matchResult = boundsPattern.find(bounds) ?: return false val (x1, y1, x2, y2) = matchResult.destructured val width = x2.toInt() - x1.toInt() val height = y2.toInt() - y1.toInt() return width == 0 || height == 0 } private fun hasNonDefaultValues(node: TreeNode, platform: String): Boolean { // Check for non-default boolean states (TreeNode properties) if (node.clickable == true) return true if (node.checked == true) return true if (node.enabled == false) return true // False is non-default if (node.focused == true) return true if (node.selected == true) return true // Get platform-specific defaults from schema val schema = createPlatformSchema(platform) @Suppress("UNCHECKED_CAST") val defaults = schema["defaults"] as Map // Check all attributes against their defaults for ((attr, value) in node.attributes) { if (value.isNullOrBlank()) continue val defaultValue = defaults[attr] val isNonDefault = when (defaultValue) { is String -> value != defaultValue is Boolean -> value.toBooleanStrictOrNull() != defaultValue is Int -> value.toIntOrNull() != defaultValue else -> !value.isNullOrBlank() // For attributes not in defaults, non-blank means meaningful } if (isNonDefault) return true } return false } /** * Converts a TreeNode to a compact map representation by: * 1. Inlining attributes directly into the output (no nested "attributes" object) * 2. Using abbreviated keys (e.g., "b" for "bounds", "txt" for "text") * 3. Including only non-default values to minimize output size * 4. Flattening the structure for easier LLM consumption */ private fun convertToCompactNode(node: TreeNode): Map { val result = mutableMapOf() // Inline attributes with abbreviated keys (only if non-empty) val bounds = node.attributes["bounds"] if (!bounds.isNullOrBlank()) result["b"] = bounds val accessibilityText = node.attributes["accessibilityText"] if (!accessibilityText.isNullOrBlank()) result["a11y"] = accessibilityText val text = node.attributes["text"] if (!text.isNullOrBlank()) result["txt"] = text val value = node.attributes["value"] if (!value.isNullOrBlank()) result["val"] = value val resourceId = node.attributes["resource-id"] if (!resourceId.isNullOrBlank()) result["rid"] = resourceId val className = node.attributes["class"] if (!className.isNullOrBlank()) result["cls"] = className // For Android, also check content-desc (maps to a11y in schema) val contentDesc = node.attributes["content-desc"] if (!contentDesc.isNullOrBlank()) result["a11y"] = contentDesc val hintText = node.attributes["hintText"] if (!hintText.isNullOrBlank()) result["hint"] = hintText val scrollable = node.attributes["scrollable"] if (scrollable == "true") result["scroll"] = true // Inline TreeNode boolean properties (only if non-default) if (node.clickable == true) result["clickable"] = true if (node.checked == true) result["checked"] = true if (node.enabled == false) result["enabled"] = false // false is non-default if (node.focused == true) result["focused"] = true if (node.selected == true) result["selected"] = true return result } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/model/FlowStatus.kt ================================================ package maestro.cli.model import maestro.cli.api.UploadStatus enum class FlowStatus { PENDING, PREPARING, INSTALLING, RUNNING, SUCCESS, ERROR, CANCELED, STOPPED, WARNING; companion object { fun from(status: UploadStatus.Status) = when (status) { UploadStatus.Status.PENDING -> PENDING UploadStatus.Status.PREPARING -> PREPARING UploadStatus.Status.INSTALLING -> INSTALLING UploadStatus.Status.RUNNING -> RUNNING UploadStatus.Status.SUCCESS -> SUCCESS UploadStatus.Status.ERROR -> ERROR UploadStatus.Status.CANCELED -> CANCELED UploadStatus.Status.WARNING -> WARNING UploadStatus.Status.STOPPED -> STOPPED } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/model/RunningFlow.kt ================================================ package maestro.cli.model import kotlin.time.Duration data class RunningFlows( val flows: List, val duration: Duration?, val startTime: Long? ) data class RunningFlow( val name: String, val status: FlowStatus, val duration: Duration? = null, val startTime: Long? = null, val reported: Boolean = false ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/model/TestExecutionSummary.kt ================================================ package maestro.cli.model import kotlin.time.Duration // TODO: Some properties should be implemented as getters, but it's not possible. // See https://github.com/Kotlin/kotlinx.serialization/issues/805 data class TestExecutionSummary( val passed: Boolean, val suites: List, val passedCount: Int? = null, val totalTests: Int? = null, ) { data class SuiteResult( val passed: Boolean, val flows: List, val duration: Duration? = null, val startTime: Long? = null, val deviceName: String? = null, ) { fun failures(): List = flows.filter { it.status == FlowStatus.ERROR } } data class FlowResult( val name: String, val fileName: String?, val status: FlowStatus, val failure: Failure? = null, val duration: Duration? = null, val startTime: Long? = null, val properties: Map? = null, val tags: List? = null, val steps: List = emptyList(), ) data class StepResult( val description: String, val status: String, val duration: String, ) data class Failure( val message: String, ) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/promotion/PromotionStateManager.kt ================================================ package maestro.cli.promotion import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import maestro.cli.util.EnvUtils import java.nio.file.Path import java.time.LocalDate import java.time.temporal.ChronoUnit import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText @JsonIgnoreProperties(ignoreUnknown = true) data class PromotionState( val fasterResultsLastShown: String? = null, val debugLastShown: String? = null, val cloudCommandLastUsed: String? = null ) /** * Manages promotion message state persistence. * Similar to AnalyticsStateManager, stores all promotion states in a single JSON file. */ class PromotionStateManager { private val promotionStatePath: Path = EnvUtils.xdgStateHome().resolve("promotion-state.json") private val JSON = jacksonObjectMapper().apply { registerModule(JavaTimeModule()) enable(SerializationFeature.INDENT_OUTPUT) } private var _promotionState: PromotionState? = null private fun getState(): PromotionState { if (_promotionState == null) { _promotionState = loadState() } return _promotionState!! } fun getLastShownDate(key: String): String? { return when (key) { "fasterResults" -> getState().fasterResultsLastShown "debug" -> getState().debugLastShown else -> null } } fun setLastShownDate(key: String, date: String) { val currentState = getState() val updatedState = when (key) { "fasterResults" -> currentState.copy(fasterResultsLastShown = date) "debug" -> currentState.copy(debugLastShown = date) else -> currentState } saveState(updatedState) } fun recordCloudCommandUsage() { val today = LocalDate.now().toString() val currentState = getState() saveState(currentState.copy(cloudCommandLastUsed = today)) } fun wasCloudCommandUsedWithinDays(days: Int): Boolean { val lastUsed = getState().cloudCommandLastUsed ?: return false val lastUsedDate = try { LocalDate.parse(lastUsed) } catch (e: Exception) { return false } val today = LocalDate.now() val daysSince = ChronoUnit.DAYS.between(lastUsedDate, today) return daysSince < days } private fun saveState(state: PromotionState) { val stateJson = JSON.writeValueAsString(state) promotionStatePath.parent.toFile().mkdirs() promotionStatePath.writeText(stateJson + "\n") _promotionState = state } private fun loadState(): PromotionState { return try { if (promotionStatePath.exists()) { JSON.readValue(promotionStatePath.readText()) } else { PromotionState() } } catch (e: Exception) { PromotionState() } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/HtmlAITestSuiteReporter.kt ================================================ package maestro.cli.report import kotlinx.html.a import kotlinx.html.body import kotlinx.html.button import kotlinx.html.div import kotlinx.html.h1 import kotlinx.html.head import kotlinx.html.html import kotlinx.html.img import kotlinx.html.lang import kotlinx.html.main import kotlinx.html.meta import kotlinx.html.p import kotlinx.html.script import kotlinx.html.span import kotlinx.html.stream.appendHTML import kotlinx.html.style import kotlinx.html.title import kotlinx.html.unsafe import readResourceAsText import java.io.File // TODO(bartekpacia): Ideally, AI output would be in the same HTML file as "normal test output". There is no inherent reason // to split those 2 streams of output ("normal" and "AI") into 2 separate HTML files. // See issue #1973 class HtmlAITestSuiteReporter { private val FlowAIOutput.htmlReportFilename get() = "ai-report-${flowName}.html" private val reportCss: String get() = readResourceAsText(this::class, "/ai_report.css") private val reportJs: String get() = readResourceAsText(this::class, "/tailwind.config.js") /** * Creates HTML files in [outputDestination] for each flow in [outputs]. */ fun report(outputs: List, outputDestination: File) { if (!outputDestination.isDirectory) throw IllegalArgumentException("Output destination must be a directory") outputs.forEachIndexed { index, output -> val htmlContent = buildHtmlReport(outputs, index) val file = File(outputDestination, output.htmlReportFilename) file.writeText(htmlContent) } } /** * Build HTML report for a single flow. * * Information about other flows is needed to generate links to them. */ private fun buildHtmlReport(outputs: List, index: Int): String { val summary = outputs[index] return buildString { appendLine("") appendHTML().html { lang = "en" head { meta { charset = "UTF-8" } meta { name = "viewport"; content = "width=device-width, initial-scale=1.0" } title { +"Maestro Test Report" } script { src = "https://cdn.tailwindcss.com/3.4.5" } script { unsafe { +reportJs } } style(type = "text/tailwindcss") { +reportCss } } body { div(classes = "flex min-h-screen flex-col") { // Header area div(classes = "container mx-auto py-6 space-y-2") { h1(classes = "text-3xl") { +"AI suggestions for flow " span(classes = "text-gray-medium") { +summary.flowName } } // File chooser for different reports div(classes = "group relative inline-block self-start") { button(classes = "btn") { +"→ Open other report" } div(classes = "absolute z-10 hidden min-w-32 group-hover:block") { outputs.forEachIndexed { outputIndex, outputFlow -> val selected = outputIndex == index a(classes = buildString { append("toggle-link") if (selected) append(" toggle-link-selected") } ) { href = "./" + outputs[outputIndex].htmlReportFilename val name = outputFlow.flowFile.nameWithoutExtension +"(${outputIndex + 1}) $name" } } } } // Link to the flow file // FIXME(bartekpacia): This path will be broken when moved across machines p { a( classes = "btn", href = summary.flowFile.absolutePath ) { +"→ Open flow file ( ${summary.flowFile.name} )" } } } // Container for list of screenshots main(classes = "container mx-auto flex flex-col gap-4") { // Overall defect count for the flow p(classes = "text-lg") { val word = if (summary.defectCount == 1) "defect" else "defects" +"${summary.defectCount} possible $word found" } // List of screenshots within flow with defects founds summary.screenOutputs.forEachIndexed { screenIndex, screenSummary -> div(classes = "screen-card") { img(classes = "screenshot-image") { alt = "Screenshot of the defect" // Use relative path, so when file is moved across machines, it still works src = screenSummary.screenshotPath.name.toString() } // defect-card-container div(classes = "flex flex-col gap-4 flex-grow") { // Defect count for the screen p(classes = "text-lg") { val word = if (screenSummary.defects.size == 1) "defect" else "defects" +"${screenSummary.defects.size} possible $word" } screenSummary.defects.forEachIndexed { i, defect -> div(classes = "defect-card") { p { +defect.reasoning } div(classes = "badge") { +defect.category } } } } } if (screenIndex != summary.screenOutputs.size - 1) { div(classes = "divider") } } } } } } } } private val FlowAIOutput.defectCount: Int get() = screenOutputs.flatMap { it.defects }.size } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/HtmlInsightsAnalysisReporter.kt ================================================ package maestro.cli.report import java.nio.file.Files import java.nio.file.Path class HtmlInsightsAnalysisReporter { fun report( html: String, outputDestination: Path ): Path { if (!Files.isDirectory(outputDestination)) { throw IllegalArgumentException("Output destination must be a directory") } val fileName = "insights-report.html" val filePath = outputDestination.resolve(fileName) Files.write(filePath, html.toByteArray()) return filePath } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/HtmlTestSuiteReporter.kt ================================================ package maestro.cli.report import kotlinx.html.* import kotlinx.html.stream.appendHTML import maestro.cli.model.TestExecutionSummary import okio.Sink import okio.buffer class HtmlTestSuiteReporter(private val detailed: Boolean = false) : TestSuiteReporter { companion object { private fun loadPrettyCss(): String { return HtmlTestSuiteReporter::class.java .getResourceAsStream("/html-detailed.css") ?.bufferedReader() ?.use { it.readText() } ?: "" } } override fun report(summary: TestExecutionSummary, out: Sink) { val bufferedOut = out.buffer() val htmlContent = buildHtmlReport(summary) bufferedOut.writeUtf8(htmlContent) bufferedOut.close() } private fun buildHtmlReport(summary: TestExecutionSummary): String { return buildString { appendHTML().html { head { title { +"Maestro Test Report" } link( rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" ) {} } body { summary.suites.forEach { suite -> val failedTests = suite.failures() div(classes = "card mb-4") { div(classes = "card-body") { h1(classes = "mt-5 text-center") { +"Flow Execution Summary" } br {} +"Test Result: ${if (suite.passed) "PASSED" else "FAILED"}" br {} +"Duration: ${suite.duration}" br {} +"Start Time: ${suite.startTime?.let { millisToCurrentLocalDateTime(it) }}" br {} br {} div(classes = "card-group mb-4") { div(classes = "card") { div(classes = "card-body") { h5(classes = "card-title text-center") { +"Total number of Flows" } h3(classes = "card-text text-center") { +"${suite.flows.size}" } } } div(classes = "card text-white bg-danger") { div(classes = "card-body") { h5(classes = "card-title text-center") { +"Failed Flows" } h3(classes = "card-text text-center") { +"${failedTests.size}" } } } div(classes = "card text-white bg-success") { div(classes = "card-body") { h5(classes = "card-title text-center") { +"Successful Flows" } h3(classes = "card-text text-center") { +"${suite.flows.size - failedTests.size}" } } } } if (failedTests.isNotEmpty()) { div(classes = "card border-danger mb-3") { div(classes = "card-body text-danger") { b { +"Failed Flow" } br {} p(classes = "card-text") { failedTests.forEach { test -> +test.name br {} } } } } } suite.flows.forEachIndexed { index, flow -> val buttonClass = if (flow.status.toString() == "ERROR") "btn btn-danger" else "btn btn-success" // Create a valid HTML ID by sanitizing the flow name val flowId = "flow-$index-${flow.name.replace(Regex("[^a-zA-Z0-9_-]"), "-")}" div(classes = "card mb-4") { div(classes = "card-header") { h5(classes = "mb-0") { button(classes = buttonClass) { attributes["type"] = "button" attributes["data-bs-toggle"] = "collapse" attributes["data-bs-target"] = "#$flowId" attributes["aria-expanded"] = "false" attributes["aria-controls"] = flowId +"${flow.name} : ${flow.status}" } } } div(classes = "collapse") { id = flowId div(classes = "card-body") { p(classes = "card-text") { +"Status: ${flow.status}" br {} +"Duration: ${flow.duration}" br {} +"Start Time: ${ flow.startTime?.let { millisToCurrentLocalDateTime( it ) } }" br {} if (flow.fileName != null) { +"File Name: ${flow.fileName}" br {} } // Display tags if present if (!flow.tags.isNullOrEmpty()) { +"Tags: " flow.tags.forEach { tag -> span(classes = "badge bg-primary me-1") { +tag } } br {} } } // Display properties if present if (!flow.properties.isNullOrEmpty()) { h6(classes = "mt-3 mb-2") { +"Properties" } div(classes = "table-responsive") { table(classes = "table table-sm table-bordered") { thead { tr { th { +"Property" } th { +"Value" } } } tbody { flow.properties.forEach { (key, value) -> tr { td { +key } td { +value } } } } } } } if (flow.failure != null) { p(classes = "card-text text-danger") { +flow.failure.message } } // Show detailed steps when detailed mode is enabled if (detailed && flow.steps.isNotEmpty()) { h6(classes = "mt-3 mb-3") { +"Test Steps (${flow.steps.size})" } flow.steps.forEach { step -> val statusIcon = when (step.status) { "COMPLETED" -> "✅" "WARNED" -> "⚠️" "FAILED" -> "❌" "SKIPPED" -> "⏭️" else -> "⚪" } div(classes = "step-item mb-2") { div(classes = "step-header d-flex justify-content-between align-items-center") { span { +"$statusIcon " span(classes = "step-name") { +step.description } } span(classes = "badge bg-light text-dark") { +step.duration } } } } } } } } } } // Add styling for step items when detailed mode is enabled if (detailed) { style { unsafe { +loadPrettyCss() } } } script( src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js", content = "" ) } } } } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/JUnitTestSuiteReporter.kt ================================================ package maestro.cli.report import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.xml.XmlMapper import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator import com.fasterxml.jackson.module.kotlin.KotlinModule import maestro.cli.model.FlowStatus import maestro.cli.model.TestExecutionSummary import okio.Sink import okio.buffer import kotlin.time.DurationUnit class JUnitTestSuiteReporter( private val mapper: ObjectMapper, private val testSuiteName: String? ) : TestSuiteReporter { private fun suiteResultToTestSuite(suite: TestExecutionSummary.SuiteResult) = TestSuite( name = testSuiteName ?: "Test Suite", device = suite.deviceName, failures = suite.failures().size, time = suite.duration?.toDouble(DurationUnit.SECONDS)?.toString(), timestamp = suite.startTime?.let { millisToCurrentLocalDateTime(it) }, tests = suite.flows.size, testCases = suite.flows .map { flow -> // Combine flow properties and tags into a single properties list val allProperties = mutableListOf() // Add custom properties flow.properties?.forEach { (key, value) -> allProperties.add(TestCaseProperty(key, value)) } // Add tags as a comma-separated property flow.tags?.takeIf { it.isNotEmpty() }?.let { tags -> allProperties.add(TestCaseProperty("tags", tags.joinToString(", "))) } TestCase( id = flow.name, name = flow.name, classname = flow.name, failure = flow.failure?.let { failure -> Failure( message = failure.message, ) }, time = flow.duration?.toDouble(DurationUnit.SECONDS)?.toString(), timestamp = flow.startTime?.let { millisToCurrentLocalDateTime(it) }, status = flow.status, properties = allProperties.takeIf { it.isNotEmpty() } ) } ) override fun report( summary: TestExecutionSummary, out: Sink ) { mapper .writerWithDefaultPrettyPrinter() .writeValue( out.buffer().outputStream(), TestSuites( suites = summary .suites .map { suiteResultToTestSuite(it) } ) ) } @JacksonXmlRootElement(localName = "testsuites") private data class TestSuites( @JacksonXmlElementWrapper(useWrapping = false) @JsonProperty("testsuite") val suites: List, ) @JacksonXmlRootElement(localName = "testsuite") private data class TestSuite( @JacksonXmlProperty(isAttribute = true) val name: String, @JacksonXmlProperty(isAttribute = true) val device: String?, @JacksonXmlProperty(isAttribute = true) val tests: Int, @JacksonXmlProperty(isAttribute = true) val failures: Int, @JacksonXmlProperty(isAttribute = true) val time: String? = null, @JacksonXmlProperty(isAttribute = true) val timestamp: String? = null, @JacksonXmlElementWrapper(useWrapping = false) @JsonProperty("testcase") val testCases: List, ) private data class TestCase( @JacksonXmlProperty(isAttribute = true) val id: String, @JacksonXmlProperty(isAttribute = true) val name: String, @JacksonXmlProperty(isAttribute = true) val classname: String, @JacksonXmlProperty(isAttribute = true) val time: String? = null, @JacksonXmlProperty(isAttribute = true) val timestamp: String? = null, @JacksonXmlProperty(isAttribute = true) val status: FlowStatus, @JacksonXmlElementWrapper(localName = "properties") @JacksonXmlProperty(localName = "property") val properties: List? = null, val failure: Failure? = null, ) private data class Failure( @JacksonXmlText val message: String, ) private data class TestCaseProperty( @JacksonXmlProperty(isAttribute = true) val name: String, @JacksonXmlProperty(isAttribute = true) val value: String, ) companion object { fun xml(testSuiteName: String? = null) = JUnitTestSuiteReporter( mapper = XmlMapper().apply { registerModule(KotlinModule.Builder().build()) setSerializationInclusion(JsonInclude.Include.NON_NULL) configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true) }, testSuiteName = testSuiteName ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/ReportFormat.kt ================================================ package maestro.cli.report import picocli.CommandLine enum class ReportFormat( val fileExtension: String?, private val displayName: String? = null ) { JUNIT(".xml"), HTML(".html"), HTML_DETAILED(".html", "HTML-DETAILED"), NOOP(null); override fun toString(): String { return displayName ?: name } class Converter : CommandLine.ITypeConverter { override fun convert(value: String): ReportFormat { // Try to match by display name first, then by enum name return values().find { it.toString().equals(value, ignoreCase = true) || it.name.equals(value, ignoreCase = true) } ?: throw IllegalArgumentException("Invalid format: $value. Valid options are: ${values().joinToString()}") } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/ReporterFactory.kt ================================================ package maestro.cli.report import maestro.cli.model.TestExecutionSummary import okio.BufferedSink object ReporterFactory { fun buildReporter(format: ReportFormat, testSuiteName: String?): TestSuiteReporter { return when (format) { ReportFormat.JUNIT -> JUnitTestSuiteReporter.xml(testSuiteName) ReportFormat.NOOP -> TestSuiteReporter.NOOP ReportFormat.HTML -> HtmlTestSuiteReporter(detailed = false) ReportFormat.HTML_DETAILED -> HtmlTestSuiteReporter(detailed = true) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt ================================================ package maestro.cli.report import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import maestro.MaestroException import maestro.TreeNode import maestro.ai.cloud.Defect import maestro.cli.runner.CommandStatus import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.IOSEnvUtils import maestro.debuglog.DebugLogStore import maestro.debuglog.LogConfig import maestro.orchestra.MaestroCommand import org.apache.logging.log4j.Level import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.core.LoggerContext import org.apache.logging.log4j.core.config.Configurator import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.nio.file.attribute.FileTime import java.time.Duration import java.time.Instant import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.util.* import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.math.log // TODO(bartekpacia): Rename to TestOutputReporter, because it's not only for "debug" stuff object TestDebugReporter { private val logger = LogManager.getLogger(TestDebugReporter::class.java) private val mapper = jacksonObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) .setSerializationInclusion(JsonInclude.Include.NON_EMPTY).writerWithDefaultPrettyPrinter() private var debugOutputPath: Path? = null private var debugOutputPathAsString: String? = null private var flattenDebugOutput: Boolean = false private var testOutputDir: Path? = null // AI outputs must be saved separately at the end of the flow. fun saveSuggestions(outputs: List, path: Path) { // This mutates the output. outputs.forEach { output -> // Write AI screenshots. Paths need to be changed to the final ones. val updatedOutputs = output.screenOutputs.map { newOutput -> val screenshotFilename = newOutput.screenshotPath.name val screenshotFile = File(path.absolutePathString(), screenshotFilename) newOutput.screenshotPath.copyTo(screenshotFile) newOutput.copy(screenshotPath = screenshotFile) } output.screenOutputs.clear() output.screenOutputs.addAll(updatedOutputs) // Write AI JSON output val jsonFilename = "ai-(${output.flowName.replace("/", "_")}).json" val jsonFile = File(path.absolutePathString(), jsonFilename) mapper.writeValue(jsonFile, output) } HtmlAITestSuiteReporter().report(outputs, path.toFile()) } /** * Save debug information about a single flow, after it has finished. */ fun saveFlow(flowName: String, debugOutput: FlowDebugOutput, path: Path, shardIndex: Int? = null) { // TODO(bartekpacia): Potentially accept a single "FlowPersistentOutput" object // TODO(bartekpacia: Build output incrementally, instead of single-shot on flow completion // Be aware that this goal somewhat conflicts with including links to other flows in the HTML report. val shardPrefix = shardIndex?.let { "shard-${it + 1}-" }.orEmpty() val shardLogPrefix = shardIndex?.let { "[shard ${it + 1}] " }.orEmpty() // commands try { val commandMetadata = debugOutput.commands if (commandMetadata.isNotEmpty()) { val commandsFilename = "commands-$shardPrefix(${flowName.replace("/", "_")}).json" val file = File(path.absolutePathString(), commandsFilename) commandMetadata.map { CommandDebugWrapper(it.key, it.value) }.let { mapper.writeValue(file, it) } } } catch (e: JsonMappingException) { logger.error("${shardLogPrefix}Unable to parse commands", e) } // screenshots debugOutput.screenshots.forEach { val status = when (it.status) { CommandStatus.COMPLETED -> "✅" CommandStatus.FAILED -> "❌" CommandStatus.WARNED -> "⚠️" else -> "﹖" } val filename = "screenshot-$shardPrefix$status-${it.timestamp}-(${flowName}).png" val file = File(path.absolutePathString(), filename) it.screenshot.copyTo(file) } } fun deleteOldFiles(days: Long = 14) { try { val currentTime = Instant.now() val daysLimit = currentTime.minus(Duration.of(days, ChronoUnit.DAYS)) val logParentDirectory = getDebugOutputPath().parent logger.info("Performing purge of logs older than $days days from ${logParentDirectory.absolutePathString()}") Files.walk(logParentDirectory).filter { val fileTime = Files.getAttribute(it, "basic:lastModifiedTime") as FileTime val isOlderThanLimit = fileTime.toInstant().isBefore(daysLimit) val shouldBeDeleted = Files.isDirectory(it) && isOlderThanLimit if (shouldBeDeleted) { logger.info("Deleting old directory: ${it.absolutePathString()}") } shouldBeDeleted }.sorted(Comparator.reverseOrder()).forEach { dir -> Files.walk(dir).sorted(Comparator.reverseOrder()).forEach { file -> Files.delete(file) } } } catch (e: Exception) { logger.warn("Failed to delete older files", e) } } private fun logSystemInfo() { logger.info("Debug output path: {}", getDebugOutputPath().absolutePathString()) // Disable specific gRPC and Netty loggers Configurator.setLevel("io.grpc.netty.NettyClientHandler", Level.OFF) Configurator.setLevel("io.grpc.netty", Level.OFF) Configurator.setLevel("io.netty", Level.OFF) val logger = LogManager.getLogger("MAESTRO") logger.info("---- System Info ----") logger.info("Maestro Version: ${EnvUtils.CLI_VERSION ?: "Undefined"}") logger.info("CI: ${CiUtils.getCiProvider() ?: "Undefined"}") logger.info("OS Name: ${EnvUtils.OS_NAME}") logger.info("OS Version: ${EnvUtils.OS_VERSION}") logger.info("Architecture: ${EnvUtils.OS_ARCH}") logger.info("Java Version: ${EnvUtils.getJavaVersion()}") logger.info("Xcode Version: ${IOSEnvUtils.xcodeVersion}") logger.info("Flutter Version: ${EnvUtils.getFlutterVersionAndChannel().first ?: "Undefined"}") logger.info("Flutter Channel: ${EnvUtils.getFlutterVersionAndChannel().second ?: "Undefined"}") logger.info("---------------------") } /** * Calls to this method should be done as soon as possible, to make all * loggers use our custom configuration rather than the defaults. */ fun install(debugOutputPathAsString: String? = null, flattenDebugOutput: Boolean = false, printToConsole: Boolean) { this.debugOutputPathAsString = debugOutputPathAsString this.flattenDebugOutput = flattenDebugOutput val path = getDebugOutputPath() LogConfig.configure(logFileName = path.absolutePathString() + "/maestro.log", printToConsole = printToConsole) logSystemInfo() DebugLogStore.logSystemInfo() } fun updateTestOutputDir(testOutputDir: Path?) { this.testOutputDir = testOutputDir // Reset debugOutputPath so getDebugOutputPath() will properly handle directory creation debugOutputPath = null } fun getDebugOutputPath(): Path { if (debugOutputPath != null) return debugOutputPath as Path val debugRootPath = if (debugOutputPathAsString != null) debugOutputPathAsString!! else System.getProperty("user.home") val debugOutput = if (flattenDebugOutput) Paths.get(debugRootPath) else buildDefaultDebugOutputPath(debugRootPath) if (!debugOutput.exists()) { Files.createDirectories(debugOutput) } debugOutputPath = debugOutput return debugOutput } private fun buildDefaultDebugOutputPath(debugRootPath: String): Path { // If testOutputDir is configured, use it as the base path instead of ~/.maestro/tests return if (testOutputDir != null) { val foldername = DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss").format(LocalDateTime.now()) testOutputDir!!.resolve(foldername) } else { val preamble = arrayOf(".maestro", "tests") val foldername = DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss").format(LocalDateTime.now()) Paths.get(debugRootPath, *preamble, foldername) } } } private data class CommandDebugWrapper( val command: MaestroCommand, val metadata: CommandDebugMetadata ) data class CommandDebugMetadata( var status: CommandStatus? = null, var timestamp: Long? = null, var duration: Long? = null, var error: Throwable? = null, var hierarchy: TreeNode? = null, var sequenceNumber: Int = 0, var evaluatedCommand: MaestroCommand? = null ) { fun calculateDuration() { if (timestamp != null) { duration = System.currentTimeMillis() - timestamp!! } } } data class FlowDebugOutput( val commands: IdentityHashMap = IdentityHashMap(), val screenshots: MutableList = mutableListOf(), var exception: MaestroException? = null, ) { data class Screenshot( val screenshot: File, val timestamp: Long, val status: CommandStatus, ) } data class FlowAIOutput( @JsonProperty("flow_name") val flowName: String, @JsonProperty("flow_file_path") val flowFile: File, @JsonProperty("outputs") val screenOutputs: MutableList = mutableListOf(), ) data class SingleScreenFlowAIOutput( @JsonProperty("screenshot_path") val screenshotPath: File, val defects: List, ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/report/TestSuiteReporter.kt ================================================ package maestro.cli.report import maestro.cli.model.TestExecutionSummary import okio.Sink import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter interface TestSuiteReporter { /** * Writes the report for [summary] to [out] in the format specified by the implementation. */ fun report( summary: TestExecutionSummary, out: Sink, ) companion object { val NOOP: TestSuiteReporter = object : TestSuiteReporter { override fun report(summary: TestExecutionSummary, out: Sink) { // no-op } } } /** * Judging from https://github.com/testmoapp/junitxml?tab=readme-ov-file#complete-junit-xml-example, * the output of timestamp needs to be an ISO 8601 local date time instead of an ISO 8601 offset date * time (it would be ideal to use ISO 8601 offset date time it needs to be confirmed if it's valid) * * Due to having to use LocalDateTime, we need to get the offset from the client (i.e. the machine running * maestro-cli) using ZoneId.systemDefault() so we can display the time relative to the client machine */ fun millisToCurrentLocalDateTime(milliseconds: Long): String { val localDateTime = Instant.ofEpochMilli(milliseconds).atZone(ZoneId.systemDefault()).toLocalDateTime() return localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/CliWatcher.kt ================================================ package maestro.cli.runner import java.io.InputStream import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.TimeUnit internal object CliWatcher { fun waitForFileChangeOrEnter(fileWatcher: FileWatcher, files: List): SignalType { val executor = Executors.newCachedThreadPool() val fileChangeFuture = CompletableFuture.supplyAsync({ fileWatcher.waitForChange(files) SignalType.FILE_CHANGE }, executor) val enterFuture = CompletableFuture.supplyAsync({ interruptibleWaitForChar(System.`in`, '\n') SignalType.ENTER }, executor) val signalType = CompletableFuture.anyOf(fileChangeFuture, enterFuture).get() as SignalType executor.shutdownNow() if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { throw InterruptedException("Timed out waiting for threads to shutdown") } return signalType } private fun interruptibleWaitForChar(inputStream: InputStream, c: Char) { while (true) { if (inputStream.available() > 0){ if (inputStream.read().toChar() == c) { return } } else { Thread.sleep(100) } } } enum class SignalType { ENTER, FILE_CHANGE } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/CommandState.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.runner import maestro.orchestra.MaestroCommand import maestro.utils.Insight data class CommandState( val status: CommandStatus, val command: MaestroCommand, val subOnStartCommands: List?, val subOnCompleteCommands: List?, val numberOfRuns: Int? = null, val subCommands: List? = null, val logMessages: List = emptyList(), val insight: Insight = Insight("", Insight.Level.NONE) ) ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/CommandStatus.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.runner enum class CommandStatus { PENDING, RUNNING, COMPLETED, FAILED, WARNED, SKIPPED, } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/FileWatcher.kt ================================================ package maestro.cli.runner import java.nio.file.FileSystems import java.nio.file.Path import java.nio.file.StandardWatchEventKinds import java.nio.file.WatchKey import kotlin.io.path.absolute class FileWatcher { private val watchService = FileSystems.getDefault().newWatchService() private val watchKeys = mutableSetOf() fun waitForChange(files: Iterable) { watchKeys.forEach(WatchKey::cancel) watchKeys.clear() val paths = files.map { it.absolute() } paths.forEach { path -> val watchKey = path.parent.register( watchService, arrayOf( StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY, ), ) watchKeys.add(watchKey) } fun isRelevantWatchKey(watchKey: WatchKey): Boolean { watchKey.pollEvents().forEach { event -> val relativePath = event.context() as Path val fullPath = (watchKey.watchable() as Path).resolve(relativePath).toAbsolutePath() if (fullPath in paths) return true } return false } while (true) { val watchKey = watchService.take() try { if (watchKey.isValid) { if (isRelevantWatchKey(watchKey)) { break } } else { watchKeys.remove(watchKey) } } finally { watchKey.reset() } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.runner import maestro.Maestro import maestro.MaestroException import maestro.device.Device import maestro.cli.report.SingleScreenFlowAIOutput import maestro.cli.report.CommandDebugMetadata import maestro.cli.report.FlowAIOutput import maestro.cli.report.FlowDebugOutput import maestro.cli.runner.resultview.ResultView import maestro.cli.runner.resultview.UiState import maestro.cli.util.PrintUtils import maestro.orchestra.ApplyConfigurationCommand import maestro.orchestra.CompositeCommand import maestro.orchestra.MaestroCommand import maestro.orchestra.Orchestra import maestro.orchestra.yaml.YamlCommandReader import maestro.utils.CliInsights import org.slf4j.LoggerFactory import java.io.File import java.util.IdentityHashMap import maestro.cli.util.ScreenshotUtils import maestro.utils.Insight import java.nio.file.Path /** * Knows how to run a list of Maestro commands and update the UI. * * Should not know what a "flow" is (apart from knowing a name, for display purposes). */ object MaestroCommandRunner { private val logger = LoggerFactory.getLogger(MaestroCommandRunner::class.java) suspend fun runCommands( flowName: String, maestro: Maestro, device: Device?, view: ResultView, commands: List, debugOutput: FlowDebugOutput, aiOutput: FlowAIOutput, apiKey: String? = null, analyze: Boolean = false, testOutputDir: Path? ): Boolean { val config = YamlCommandReader.getConfig(commands) val onFlowComplete = config?.onFlowComplete val onFlowStart = config?.onFlowStart val commandStatuses = IdentityHashMap() val commandMetadata = IdentityHashMap() fun refreshUi() { view.setState( UiState.Running( flowName = flowName, device = device, onFlowStartCommands = toCommandStates( onFlowStart?.commands ?: emptyList(), commandStatuses, commandMetadata ), onFlowCompleteCommands = toCommandStates( onFlowComplete?.commands ?: emptyList(), commandStatuses, commandMetadata ), commands = toCommandStates( commands, commandStatuses, commandMetadata ) ) ) } refreshUi() if (analyze) { ScreenshotUtils.takeDebugScreenshotByCommand(maestro, debugOutput, CommandStatus.PENDING) } var commandSequenceNumber = 0 val orchestra = Orchestra( maestro = maestro, screenshotsDir = testOutputDir?.resolve("screenshots"), insights = CliInsights, onCommandStart = { _, command -> logger.info("${command.description()} RUNNING") commandStatuses[command] = CommandStatus.RUNNING debugOutput.commands[command] = CommandDebugMetadata( timestamp = System.currentTimeMillis(), status = CommandStatus.RUNNING, sequenceNumber = commandSequenceNumber++ ) refreshUi() }, onCommandComplete = { _, command -> logger.info("${command.description()} COMPLETED") commandStatuses[command] = CommandStatus.COMPLETED if (analyze) { ScreenshotUtils.takeDebugScreenshotByCommand(maestro, debugOutput, CommandStatus.COMPLETED) } debugOutput.commands[command]?.apply { status = CommandStatus.COMPLETED calculateDuration() } refreshUi() }, onCommandFailed = { _, command, e -> debugOutput.commands[command]?.apply { status = CommandStatus.FAILED calculateDuration() error = e } ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.FAILED) if (e !is MaestroException) { throw e } else { debugOutput.exception = e } logger.info("${command.description()} FAILED") commandStatuses[command] = CommandStatus.FAILED refreshUi() Orchestra.ErrorResolution.FAIL }, onCommandSkipped = { _, command -> logger.info("${command.description()} SKIPPED") commandStatuses[command] = CommandStatus.SKIPPED debugOutput.commands[command]?.apply { status = CommandStatus.SKIPPED } refreshUi() }, onCommandWarned = { _, command -> logger.info("${command.description()} WARNED") commandStatuses[command] = CommandStatus.WARNED debugOutput.commands[command]?.apply { status = CommandStatus.WARNED } ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.WARNED) refreshUi() }, onCommandReset = { command -> logger.info("${command.description()} PENDING") commandStatuses[command] = CommandStatus.PENDING debugOutput.commands[command]?.apply { status = CommandStatus.PENDING } refreshUi() }, onCommandMetadataUpdate = { command, metadata -> logger.info("${command.description()} metadata $metadata") commandMetadata[command] = metadata // Update debug output with evaluated command for interpolated labels debugOutput.commands[command]?.evaluatedCommand = metadata.evaluatedCommand refreshUi() }, onCommandGeneratedOutput = { command, defects, screenshot -> logger.info("${command.description()} generated output") val screenshotPath = ScreenshotUtils.writeAIscreenshot(screenshot) aiOutput.screenOutputs.add( SingleScreenFlowAIOutput( screenshotPath = screenshotPath, defects = defects, ) ) }, apiKey = apiKey, ) val flowSuccess = orchestra.runFlow(commands) // Warn users about deprecated Rhino JS engine val isRhinoExplicitlyRequested = config?.ext?.get("jsEngine") == "rhino" if (isRhinoExplicitlyRequested) { PrintUtils.warn("⚠️ The Rhino JS engine (jsEngine: rhino) is deprecated and will be removed in a future version. Please migrate to GraalJS (the default) for better performance and compatibility. This warning will be removed in a future version.") } return flowSuccess } private fun toCommandStates( commands: List, commandStatuses: MutableMap, commandMetadata: IdentityHashMap, ): List { return commands // Don't render configuration commands .filter { it.asCommand() !is ApplyConfigurationCommand } .mapIndexed { _, command -> CommandState( command = commandMetadata[command]?.evaluatedCommand ?: command, subOnStartCommands = (command.asCommand() as? CompositeCommand) ?.config() ?.onFlowStart ?.let { toCommandStates(it.commands, commandStatuses, commandMetadata) }, subOnCompleteCommands = (command.asCommand() as? CompositeCommand) ?.config() ?.onFlowComplete ?.let { toCommandStates(it.commands, commandStatuses, commandMetadata) }, status = commandStatuses[command] ?: CommandStatus.PENDING, numberOfRuns = commandMetadata[command]?.numberOfRuns, subCommands = (command.asCommand() as? CompositeCommand) ?.subCommands() ?.let { toCommandStates(it, commandStatuses, commandMetadata) }, logMessages = commandMetadata[command]?.logMessages ?: emptyList(), insight = commandMetadata[command]?.insight ?: Insight("", Insight.Level.NONE) ) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt ================================================ package maestro.cli.runner import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.get import com.github.michaelbull.result.getOr import com.github.michaelbull.result.onFailure import kotlinx.coroutines.runBlocking import maestro.Maestro import maestro.MaestroException import maestro.device.Device import maestro.cli.report.FlowAIOutput import maestro.cli.report.FlowDebugOutput import maestro.cli.report.TestDebugReporter import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.runner.resultview.ResultView import maestro.cli.runner.resultview.UiState import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils import maestro.cli.view.ErrorViewUtils import maestro.orchestra.MaestroCommand import maestro.orchestra.util.Env.withEnv import maestro.orchestra.util.Env.withDefaultEnvVars import maestro.orchestra.util.Env.withInjectedShellEnvVars import maestro.orchestra.yaml.YamlCommandReader import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Path import kotlin.concurrent.thread /** * Knows how to run a single Maestro flow (either one-shot or continuously). */ object TestRunner { private val logger = LoggerFactory.getLogger(TestRunner::class.java) /** * Runs a single flow, one-shot style. * * If the flow generates artifacts, they should be placed in [debugOutputPath]. */ fun runSingle( maestro: Maestro, device: Device?, flowFile: File, env: Map, resultView: ResultView, debugOutputPath: Path, analyze: Boolean = false, apiKey: String? = null, testOutputDir: Path?, deviceId: String?, ): Int { val debugOutput = FlowDebugOutput() var aiOutput = FlowAIOutput( flowName = flowFile.nameWithoutExtension, flowFile = flowFile, ) val updatedEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(flowFile, deviceId) val result = runCatching(resultView, maestro) { val commands = YamlCommandReader.readCommands(flowFile.toPath()) .withEnv(updatedEnv) val flowName = YamlCommandReader.getConfig(commands)?.name ?: flowFile.nameWithoutExtension aiOutput = aiOutput.copy(flowName = flowName) logger.info("Running flow ${flowFile.name}...") runBlocking { MaestroCommandRunner.runCommands( flowName = flowName, maestro = maestro, device = device, view = resultView, commands = commands, debugOutput = debugOutput, aiOutput = aiOutput, analyze = analyze, apiKey = apiKey, testOutputDir = testOutputDir, ) } } TestDebugReporter.saveFlow( flowName = flowFile.name, debugOutput = debugOutput, path = debugOutputPath, ) TestDebugReporter.saveSuggestions( outputs = listOf(aiOutput), path = debugOutputPath, ) val exception = debugOutput.exception if (exception != null) { PrintUtils.err(exception.message) if (exception is MaestroException.AssertionFailure) { PrintUtils.err(exception.debugMessage) } else if (exception is MaestroException.HideKeyboardFailure) { PrintUtils.err(exception.debugMessage) } else { val debugMessage = (exception as? MaestroException.DriverTimeout)?.debugMessage if (exception is MaestroException.DriverTimeout && debugMessage != null) { PrintUtils.err(debugMessage) } } } return if (result.get() == true) 0 else 1 } /** * Runs a single flow continuously. */ fun runContinuous( maestro: Maestro, device: Device?, flowFile: File, env: Map, analyze: Boolean = false, apiKey: String? = null, testOutputDir: Path?, deviceId: String?, ): Nothing { val resultView = AnsiResultView("> Press [ENTER] to restart the Flow\n\n", useEmojis = !EnvUtils.isWindows()) val fileWatcher = FileWatcher() var previousCommands: List? = null var ongoingTest: Thread? = null do { val watchFiles = runCatching(resultView, maestro) { ongoingTest?.apply { interrupt() join() } val updatedEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(flowFile, deviceId) val commands = YamlCommandReader .readCommands(flowFile.toPath()) .withEnv(updatedEnv) val flowName = YamlCommandReader.getConfig(commands)?.name // Restart the flow if anything has changed if (commands != previousCommands) { ongoingTest = thread { previousCommands = commands runCatching(resultView, maestro) { runBlocking { MaestroCommandRunner.runCommands( flowName = flowName ?: flowFile.nameWithoutExtension, maestro = maestro, device = device, view = resultView, commands = commands, debugOutput = FlowDebugOutput(), // TODO(bartekpacia): make AI outputs work in continuous mode (see #1972) aiOutput = FlowAIOutput( flowName = "TODO", flowFile = flowFile, ), analyze = analyze, apiKey = apiKey, testOutputDir = testOutputDir ) } }.get() } } YamlCommandReader.getWatchFiles(flowFile.toPath()) } .onFailure { previousCommands = null } .getOr(listOf(flowFile.toPath())) if (CliWatcher.waitForFileChangeOrEnter(fileWatcher, watchFiles) == CliWatcher.SignalType.ENTER) { // On ENTER force re-run of flow even if commands have not changed previousCommands = null } } while (true) } private fun runCatching( view: ResultView, maestro: Maestro, block: () -> T, ): Result { return try { Ok(block()) } catch (e: Exception) { logger.error("Failed to run flow", e) val message = ErrorViewUtils.exceptionToMessage(e) if (!maestro.isShutDown()) { view.setState( UiState.Error( message = message ) ) } return Err(e) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt ================================================ package maestro.cli.runner import maestro.Maestro import maestro.MaestroException import maestro.cli.CliError import maestro.device.Device import maestro.cli.model.FlowStatus import maestro.cli.model.TestExecutionSummary import maestro.cli.report.SingleScreenFlowAIOutput import maestro.cli.report.CommandDebugMetadata import maestro.cli.report.FlowAIOutput import maestro.cli.report.FlowDebugOutput import maestro.cli.report.TestDebugReporter import maestro.cli.report.TestSuiteReporter import maestro.cli.util.PrintUtils import maestro.cli.util.TimeUtils import maestro.cli.view.ErrorViewUtils import maestro.cli.view.TestSuiteStatusView import maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel import maestro.orchestra.Orchestra import maestro.orchestra.util.Env.withEnv import maestro.orchestra.workspace.WorkspaceExecutionPlanner import maestro.orchestra.yaml.YamlCommandReader import okio.Sink import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Path import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds import maestro.cli.util.ScreenshotUtils import maestro.orchestra.util.Env.withDefaultEnvVars import maestro.orchestra.util.Env.withInjectedShellEnvVars /** * Similar to [TestRunner], but: * * can run many flows at once * * does not support continuous mode * * Does not care about sharding. It only has to know the index of the shard it's running it, for logging purposes. */ class TestSuiteInteractor( private val maestro: Maestro, private val device: Device? = null, private val reporter: TestSuiteReporter, private val shardIndex: Int? = null, private val captureSteps: Boolean = false, ) { private val logger = LoggerFactory.getLogger(TestSuiteInteractor::class.java) private val shardPrefix = shardIndex?.let { "[shard ${it + 1}] " }.orEmpty() suspend fun runTestSuite( executionPlan: WorkspaceExecutionPlanner.ExecutionPlan, reportOut: Sink?, env: Map, debugOutputPath: Path, testOutputDir: Path? = null, deviceId: String? = null, ): TestExecutionSummary { if (executionPlan.flowsToRun.isEmpty() && executionPlan.sequence.flows.isEmpty()) { throw CliError("${shardPrefix}No flows returned from the tag filter used") } val flowResults = mutableListOf() PrintUtils.message("${shardPrefix}Waiting for flows to complete...") var passed = true val aiOutputs = mutableListOf() // first run sequence of flows if present val flowSequence = executionPlan.sequence for (flow in flowSequence.flows) { val flowFile = flow.toFile() val updatedEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(flowFile, deviceId, shardIndex) val (result, aiOutput) = runFlow(flowFile, updatedEnv, maestro, debugOutputPath, testOutputDir) flowResults.add(result) aiOutputs.add(aiOutput) if (result.status == FlowStatus.ERROR) { passed = false if (executionPlan.sequence.continueOnFailure != true) { PrintUtils.message("${shardPrefix}Flow ${result.name} failed and continueOnFailure is set to false, aborting running sequential Flows") println() break } } } // proceed to run all other Flows executionPlan.flowsToRun.forEach { flow -> val flowFile = flow.toFile() val updatedEnv = env .withInjectedShellEnvVars() .withDefaultEnvVars(flowFile, deviceId, shardIndex) val (result, aiOutput) = runFlow(flowFile, updatedEnv, maestro, debugOutputPath, testOutputDir) aiOutputs.add(aiOutput) if (result.status == FlowStatus.ERROR) { passed = false } flowResults.add(result) } val suiteDuration = flowResults.sumOf { it.duration?.inWholeSeconds ?: 0 }.seconds TestSuiteStatusView.showSuiteResult( TestSuiteViewModel( status = if (passed) FlowStatus.SUCCESS else FlowStatus.ERROR, duration = suiteDuration, shardIndex = shardIndex, flows = flowResults .map { TestSuiteViewModel.FlowResult( name = it.name, status = it.status, duration = it.duration, ) }, ), uploadUrl = "" ) val summary = TestExecutionSummary( passed = passed, suites = listOf( TestExecutionSummary.SuiteResult( passed = passed, flows = flowResults, duration = suiteDuration, deviceName = device?.description, ) ), passedCount = flowResults.count { it.status == FlowStatus.SUCCESS }, totalTests = flowResults.size ) if (reportOut != null) { reporter.report( summary, reportOut, ) } // TODO(bartekpacia): Should it also be saving to debugOutputPath? TestDebugReporter.saveSuggestions(aiOutputs, debugOutputPath) return summary } private suspend fun runFlow( flowFile: File, env: Map, maestro: Maestro, debugOutputPath: Path, testOutputDir: Path? = null ): Pair { // TODO(bartekpacia): merge TestExecutionSummary with AI suggestions // (i.e. consider them also part of the test output) // See #1973 var flowStatus: FlowStatus var errorMessage: String? = null val debugOutput = FlowDebugOutput() val aiOutput = FlowAIOutput( flowName = flowFile.nameWithoutExtension, flowFile = flowFile, ) val commands = YamlCommandReader .readCommands(flowFile.toPath()) .withEnv(env) val maestroConfig = YamlCommandReader.getConfig(commands) val flowName: String = maestroConfig?.name ?: flowFile.nameWithoutExtension logger.info("$shardPrefix Running flow $flowName") val flowTimeMillis = measureTimeMillis { try { var commandSequenceNumber = 0 val orchestra = Orchestra( maestro = maestro, screenshotsDir = testOutputDir?.resolve("screenshots"), onCommandStart = { _, command -> logger.info("${shardPrefix}${command.description()} RUNNING") debugOutput.commands[command] = CommandDebugMetadata( timestamp = System.currentTimeMillis(), status = CommandStatus.RUNNING, sequenceNumber = commandSequenceNumber++ ) }, onCommandComplete = { _, command -> logger.info("${shardPrefix}${command.description()} COMPLETED") debugOutput.commands[command]?.let { it.status = CommandStatus.COMPLETED it.calculateDuration() } }, onCommandFailed = { _, command, e -> logger.info("${shardPrefix}${command.description()} FAILED") if (e is MaestroException) debugOutput.exception = e debugOutput.commands[command]?.let { it.status = CommandStatus.FAILED it.calculateDuration() it.error = e } ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.FAILED) Orchestra.ErrorResolution.FAIL }, onCommandSkipped = { _, command -> logger.info("${shardPrefix}${command.description()} SKIPPED") debugOutput.commands[command]?.let { it.status = CommandStatus.SKIPPED } }, onCommandWarned = { _, command -> logger.info("${shardPrefix}${command.description()} WARNED") debugOutput.commands[command]?.apply { status = CommandStatus.WARNED } }, onCommandReset = { command -> logger.info("${shardPrefix}${command.description()} PENDING") debugOutput.commands[command]?.let { it.status = CommandStatus.PENDING } }, onCommandGeneratedOutput = { command, defects, screenshot -> logger.info("${shardPrefix}${command.description()} generated output") val screenshotPath = ScreenshotUtils.writeAIscreenshot(screenshot) aiOutput.screenOutputs.add( SingleScreenFlowAIOutput( screenshotPath = screenshotPath, defects = defects, ) ) } ) val flowSuccess = orchestra.runFlow(commands) flowStatus = if (flowSuccess) FlowStatus.SUCCESS else FlowStatus.ERROR } catch (e: Exception) { logger.error("${shardPrefix}Failed to complete flow", e) flowStatus = FlowStatus.ERROR errorMessage = ErrorViewUtils.exceptionToMessage(e) } } val flowDuration = TimeUtils.durationInSeconds(flowTimeMillis) TestDebugReporter.saveFlow( flowName = flowName, debugOutput = debugOutput, shardIndex = shardIndex, path = debugOutputPath, ) // FIXME(bartekpacia): Save AI output as well TestSuiteStatusView.showFlowCompletion( TestSuiteViewModel.FlowResult( name = flowName, status = flowStatus, duration = flowDuration, shardIndex = shardIndex, error = debugOutput.exception?.message, ) ) // Extract step information if captureSteps is enabled val steps = if (captureSteps) { debugOutput.commands.entries .sortedBy { it.value.sequenceNumber } .mapIndexed { index, (command, metadata) -> val durationStr = when (val duration = metadata.duration) { null -> "<1ms" else -> if (duration >= 1000) { "%.1fs".format(duration / 1000.0) } else { "${duration}ms" } } val status = metadata.status?.toString() ?: "UNKNOWN" // Use evaluated command for interpolated labels, fallback to original val displayCommand = metadata.evaluatedCommand ?: command TestExecutionSummary.StepResult( description = "${index + 1}. ${displayCommand.description()}", status = status, duration = durationStr, ) } } else { emptyList() } return Pair( first = TestExecutionSummary.FlowResult( name = flowName, fileName = flowFile.nameWithoutExtension, status = flowStatus, failure = if (flowStatus == FlowStatus.ERROR) { TestExecutionSummary.Failure( message = shardPrefix + (errorMessage ?: debugOutput.exception?.message ?: "Unknown error"), ) } else null, duration = flowDuration, properties = maestroConfig?.properties, tags = maestroConfig?.tags, steps = steps, ), second = aiOutput, ) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.runner.resultview import io.ktor.util.encodeBase64 import maestro.device.Device import maestro.device.Platform import maestro.cli.runner.CommandState import maestro.cli.runner.CommandStatus import maestro.device.DeviceSpecRequest import maestro.device.DeviceSpec import maestro.orchestra.AssertWithAICommand import maestro.orchestra.ElementSelector import maestro.orchestra.LaunchAppCommand import maestro.orchestra.MaestroCommand import maestro.orchestra.TapOnElementCommand import maestro.orchestra.TapOnPointV2Command import maestro.utils.Insight import maestro.utils.chunkStringByWordCount import org.fusesource.jansi.Ansi class AnsiResultView( private val prompt: String? = null, private val printCommandLogs: Boolean = true, private val useEmojis: Boolean = true, ) : ResultView { private val startTimestamp = System.currentTimeMillis() private val frames = mutableListOf() private var previousFrame: String? = null init { println(Ansi.ansi().eraseScreen()) } override fun setState(state: UiState) { when (state) { is UiState.Running -> renderRunningState(state) is UiState.Error -> renderErrorState(state) } } fun getFrames(): List { return frames.toList() } private fun renderErrorState(state: UiState.Error) { renderFrame { fgRed() render(state.message) render("\n") } } private fun renderRunningState(state: UiState.Running) = renderFrame { state.device?.let { render("Running on ${state.device.description}\n") } render("\n") if (state.onFlowStartCommands.isNotEmpty()) { render(" ║\n") render(" ║ > On Flow Start\n") render(" ║\n") renderCommands(state.onFlowStartCommands) } render(" ║\n") render(" ║ > Flow: ${state.flowName}\n") render(" ║\n") renderCommands(state.commands) render(" ║\n") if (state.onFlowCompleteCommands.isNotEmpty()) { render(" ║\n") render(" ║ > On Flow Complete\n") render(" ║\n") renderCommands(state.onFlowCompleteCommands) } renderPrompt() } private fun Ansi.renderPrompt() { prompt?.let { render(" ║\n") render(" ║ $prompt\n") } } private fun Ansi.renderCommands( commands: List, indent: Int = 0, ) { commands .filter { it.command.asCommand()?.visible() ?: true } .forEach { renderCommand(it, indent) } } private fun Ansi.renderCommand(commandState: CommandState, indent: Int) { val statusSymbol = status(commandState.status) fgDefault() renderLineStart(indent) render(statusSymbol) render(" ".repeat(2)) render( commandState.command.description() .replace("(? "@|cyan ${match.value}|@" } ) if (commandState.status == CommandStatus.SKIPPED) { render(" (skipped)") } else if (commandState.status == CommandStatus.WARNED) { render(" (warned)") } else if (commandState.numberOfRuns != null) { val timesWord = if (commandState.numberOfRuns == 1) "time" else "times" render(" (completed ${commandState.numberOfRuns} $timesWord)") } render("\n") if (printCommandLogs && commandState.logMessages.isNotEmpty()) { printLogMessages(indent, commandState) } if (commandState.insight.level != Insight.Level.NONE) { printInsight(indent, commandState.insight) } val subCommandsHasNotPending = (commandState.subCommands ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false) val onStartHasNotPending = (commandState.subOnStartCommands ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false) val onCompleteHasNotPending = (commandState.subOnCompleteCommands ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false) val expandSubCommands = commandState.status in setOf(CommandStatus.RUNNING, CommandStatus.FAILED) && (subCommandsHasNotPending || onStartHasNotPending || onCompleteHasNotPending) if (expandSubCommands) { commandState.subOnStartCommands?.let { render(" ║\n") render(" ║ > On Flow Start\n") render(" ║\n") renderCommands(it) } commandState.subCommands?.let { subCommands -> renderCommands(subCommands, indent + 1) } commandState.subOnCompleteCommands?.let { render(" ║\n") render(" ║ > On Flow Complete\n") render(" ║\n") renderCommands(it) } } } private fun Ansi.printLogMessages(indent: Int, commandState: CommandState) { renderLineStart(indent + 1) render(" ") // Space that a status symbol would normally occupy render("@|yellow Log messages:|@\n") commandState.logMessages.forEach { renderLineStart(indent + 2) render(" ") // Space that a status symbol would normally occupy render(it) render("\n") } } private fun Ansi.printInsight(indent: Int, insight: Insight) { val color = when (insight.level) { Insight.Level.WARNING -> "yellow" Insight.Level.INFO -> "cyan" else -> "default" } val level = insight.level.toString().lowercase().replaceFirstChar(Char::uppercase) renderLineStart(indent + 1) render(" ") // Space that a status symbol would normally occupy render("@|$color $level:|@\n") insight.message.split("\n").forEach { paragraph -> paragraph.chunkStringByWordCount(12).forEach { chunkedMessage -> renderLineStart(indent + 2) render(" ") // Space that a status symbol would normally occupy render(chunkedMessage) render("\n") } } } private fun Ansi.renderLineStart(indent: Int) { render(" ║ ") repeat(indent) { render(" ") } } private fun renderFrame(block: Ansi.() -> Any) { renderFrame(StringBuilder().apply { val ansi = Ansi().cursor(0, 0) ansi.block() append(ansi) }.toString()) } private fun renderFrame(frame: String) { // Clear previous frame previousFrame?.let { previousFrame -> val lines = previousFrame.lines() val height = lines.size val width = lines.maxOf { it.length } Ansi.ansi().let { ansi -> ansi.cursor(0, 0) repeat(height) { ansi.render(" ".repeat(width)) ansi.render("\n") } ansi.cursor(0, 0) println(ansi) } } print(frame) frames.add(createFrame(frame)) previousFrame = frame } private fun createFrame(frame: String): Frame { val content = frame.encodeBase64() return Frame(System.currentTimeMillis() - startTimestamp, content) } private fun status(status: CommandStatus): String { if (useEmojis) { return when (status) { CommandStatus.COMPLETED -> "✅ " CommandStatus.FAILED -> "❌ " CommandStatus.RUNNING -> "⏳ " CommandStatus.PENDING -> "\uD83D\uDD32 " // 🔲 CommandStatus.WARNED -> "⚠️ " CommandStatus.SKIPPED -> "⚪️ " } } else { return when (status) { CommandStatus.COMPLETED -> "+ " CommandStatus.FAILED -> "X " CommandStatus.RUNNING -> "> " CommandStatus.PENDING -> " " CommandStatus.WARNED -> "! " CommandStatus.SKIPPED -> "- " } } } data class Frame(val timestamp: Long, val content: String) } // Helper launcher to play around with presentation fun main() { val view = AnsiResultView("> Press [ENTER] to restart the Flow\n") view.setState( UiState.Running( flowName = "Flow for playing around", device = Device.Connected( instanceId = "device", deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Android() ), description = "description", platform = Platform.ANDROID, deviceType = Device.DeviceType.EMULATOR ), onFlowStartCommands = listOf(), onFlowCompleteCommands = listOf(), commands = listOf( CommandState( command = MaestroCommand(launchAppCommand = LaunchAppCommand("com.example.example")), status = CommandStatus.COMPLETED, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), ), CommandState( command = MaestroCommand( assertWithAICommand = AssertWithAICommand( assertion = "There are no bananas visible", optional = true ), ), status = CommandStatus.WARNED, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), insight = Insight( message = """ |Assertion is false: There are no bananas visible |Reasoning: The screen shows a login screen and no images of bananas are present. """.trimMargin(), level = Insight.Level.WARNING, ), ), CommandState( command = MaestroCommand( tapOnElement = TapOnElementCommand( selector = ElementSelector("id", "login") ), ), status = CommandStatus.SKIPPED, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), ), CommandState( command = MaestroCommand( tapOnElement = TapOnElementCommand( selector = ElementSelector("id", "login"), label = "Use JS value: \${output.some_var}", ), ), status = CommandStatus.RUNNING, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), ), CommandState( command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = "50%, 25%")), status = CommandStatus.PENDING, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), ), CommandState( command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = "50%, 25%")), status = CommandStatus.FAILED, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), insight = Insight("This is insight message", Insight.Level.NONE), ), CommandState( command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = "50%, 25%")), status = CommandStatus.FAILED, subOnStartCommands = listOf(), subOnCompleteCommands = listOf(), insight = Insight("This is an error message", Insight.Level.INFO), ), ) ) ) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/resultview/PlainTextResultView.kt ================================================ package maestro.cli.runner.resultview import maestro.cli.runner.CommandState import maestro.cli.runner.CommandStatus import maestro.orchestra.CompositeCommand import maestro.utils.Insight import maestro.utils.chunkStringByWordCount class PlainTextResultView: ResultView { private val printed = mutableSetOf() private val terminalStatuses = setOf( CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.SKIPPED, CommandStatus.WARNED ) private inline fun printOnce(key: String, block: () -> Unit) { if (printed.add(key)) block() } override fun setState(state: UiState) { when (state) { is UiState.Running -> renderRunningState(state) is UiState.Error -> renderErrorState(state) } } private fun renderErrorState(state: UiState.Error) { println(state.message) } private fun renderRunningState(state: UiState.Running) { renderRunningStatePlainText(state) } private fun renderRunningStatePlainText(state: UiState.Running) { state.device?.let { printOnce("device") { println("Running on ${state.device.description}") } } if (state.onFlowStartCommands.isNotEmpty()) { printOnce("onFlowStart") { println(" > On Flow Start") } renderCommandsPlainText(state.onFlowStartCommands, prefix = "onFlowStart") } printOnce("flowName:${state.flowName}") { println(" > Flow ${state.flowName}") } renderCommandsPlainText(state.commands, prefix = "main") if (state.onFlowCompleteCommands.isNotEmpty()) { printOnce("onFlowComplete") { println(" > On Flow Complete") } renderCommandsPlainText(state.onFlowCompleteCommands, prefix = "onFlowComplete") } } private fun renderCommandsPlainText(commands: List, indent: Int = 0, prefix: String = "") { for ((index, command) in commands.withIndex()) { renderCommandPlainText(command, indent, "$prefix:$index") } } private fun renderCommandPlainText(command: CommandState, indent: Int, key: String) { val c = command.command.asCommand() if (c?.visible() == false) return val desc = c?.description() ?: "Unknown command" val pad = " ".repeat(indent) when (c) { is CompositeCommand -> { // Print start line once when command begins if (command.status != CommandStatus.PENDING) { printOnce("$key:start") { println("$pad$desc...") } } // onFlowStart hooks command.subOnStartCommands?.let { cmds -> printOnce("$key:onStart") { println("$pad > On Flow Start") } renderCommandsPlainText(cmds, indent + 1, "$key:subOnStart") } // The actual sub-commands of the composite command.subCommands?.let { cmds -> renderCommandsPlainText(cmds, indent + 1, "$key:sub") } // onFlowComplete hooks command.subOnCompleteCommands?.let { cmds -> printOnce("$key:onComplete") { println("$pad > On Flow Complete") } renderCommandsPlainText(cmds, indent + 1, "$key:subOnComplete") } // Print completion line once when it reaches a terminal status if (command.status in terminalStatuses) { printOnce("$key:complete") { println("$pad$desc... ${status(command.status)}") } } } else -> { // Simple command (tapOn, assertVisible, etc.) when (command.status) { CommandStatus.RUNNING -> { printOnce("$key:start") { print("$pad$desc...") } } in terminalStatuses -> { printOnce("$key:start") { print("$pad$desc...") } printOnce("$key:complete") { println(" ${status(command.status)}") renderInsight(command.insight, indent + 1) } } else -> {} } } } } private fun renderInsight(insight: Insight, indent: Int) { if (insight.level != Insight.Level.NONE) { println("\n") val level = insight.level.toString().lowercase().replaceFirstChar(Char::uppercase) print(" ".repeat(indent) + level + ":") insight.message.chunkStringByWordCount(12).forEach { chunkedMessage -> print(" ".repeat(indent)) print(chunkedMessage) print("\n") } } } private fun status(status: CommandStatus): String { return when (status) { CommandStatus.COMPLETED -> "COMPLETED" CommandStatus.FAILED -> "FAILED" CommandStatus.RUNNING -> "RUNNING" CommandStatus.PENDING -> "PENDING" CommandStatus.SKIPPED -> "SKIPPED" CommandStatus.WARNED -> "WARNED" } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/resultview/ResultView.kt ================================================ package maestro.cli.runner.resultview interface ResultView { fun setState(state: UiState) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/runner/resultview/UiState.kt ================================================ package maestro.cli.runner.resultview import maestro.device.Device import maestro.cli.runner.CommandState sealed class UiState { data class Error(val message: String) : UiState() data class Running( val flowName: String, val device: Device? = null, val onFlowStartCommands: List = emptyList(), val onFlowCompleteCommands: List = emptyList(), val commands: List, ) : UiState() } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/session/MaestroSessionManager.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.cli.session import dadb.Dadb import dadb.adbserver.AdbServer import ios.LocalIOSDevice import ios.devicectl.DeviceControlIOSDevice import device.SimctlIOSDevice import ios.xctest.XCTestIOSDevice import maestro.Maestro import maestro.device.Device import maestro.cli.device.PickDeviceInteractor import maestro.cli.driver.DriverBuilder import maestro.cli.driver.RealIOSDeviceDriver import maestro.cli.util.PrintUtils import maestro.device.Platform import maestro.utils.CliInsights import maestro.cli.util.ScreenReporter import maestro.drivers.AndroidDriver import maestro.drivers.IOSDriver import maestro.orchestra.WorkspaceConfig.PlatformConfiguration import maestro.orchestra.workspace.WorkspaceExecutionPlanner import maestro.utils.TempFileHandler import org.slf4j.LoggerFactory import util.IOSDeviceType import util.XCRunnerCLIUtils import xcuitest.XCTestClient import xcuitest.XCTestDriverClient import xcuitest.installer.Context import xcuitest.installer.LocalXCTestInstaller import xcuitest.installer.LocalXCTestInstaller.* import java.nio.file.Paths import java.util.UUID import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.io.path.pathString object MaestroSessionManager { private const val defaultHost = "localhost" private const val defaultXctestHost = "127.0.0.1" private const val defaultXcTestPort = 22087 private val executor = Executors.newScheduledThreadPool(1) private val logger = LoggerFactory.getLogger(MaestroSessionManager::class.java) fun newSession( host: String?, port: Int?, driverHostPort: Int?, deviceId: String?, teamId: String? = null, platform: String? = null, isStudio: Boolean = false, isHeadless: Boolean = false, screenSize: String? = null, reinstallDriver: Boolean = true, deviceIndex: Int? = null, executionPlan: WorkspaceExecutionPlanner.ExecutionPlan? = null, block: (MaestroSession) -> T, ): T { val selectedDevice = selectDevice( host = host, port = port, driverHostPort = driverHostPort, deviceId = deviceId, teamId = teamId, platform = if(!platform.isNullOrEmpty()) Platform.fromString(platform) else null, deviceIndex = deviceIndex, ) val sessionId = UUID.randomUUID().toString() val heartbeatFuture = executor.scheduleAtFixedRate( { try { Thread.sleep(1000) // Add a 1-second delay here for fixing race condition SessionStore.heartbeat(sessionId, selectedDevice.platform) } catch (e: Exception) { logger.error("Failed to record heartbeat", e) } }, 0L, 5L, TimeUnit.SECONDS ) val session = createMaestro( selectedDevice = selectedDevice, connectToExistingSession = if (isStudio) { false } else { SessionStore.hasActiveSessions( sessionId, selectedDevice.platform ) }, isStudio = isStudio, isHeadless = isHeadless, screenSize = screenSize, driverHostPort = driverHostPort, reinstallDriver = reinstallDriver, platformConfiguration = executionPlan?.workspaceConfig?.platform ) Runtime.getRuntime().addShutdownHook(thread(start = false) { heartbeatFuture.cancel(true) SessionStore.delete(sessionId, selectedDevice.platform) runCatching { ScreenReporter.reportMaxDepth() } if (SessionStore.activeSessions().isEmpty()) { session.close() } }) return block(session) } private fun selectDevice( host: String?, port: Int?, driverHostPort: Int?, deviceId: String?, platform: Platform? = null, teamId: String? = null, deviceIndex: Int? = null, ): SelectedDevice { if (deviceId == "chromium" || platform == Platform.WEB) { return SelectedDevice( platform = Platform.WEB, deviceType = Device.DeviceType.BROWSER ) } if (host == null) { val device = PickDeviceInteractor.pickDevice(deviceId, driverHostPort, platform, deviceIndex) if (device.deviceType == Device.DeviceType.REAL && device.platform == Platform.IOS) { PrintUtils.message("Detected connected iPhone with ${device.instanceId}!") val driverBuilder = DriverBuilder() RealIOSDeviceDriver( destination = "platform=iOS,id=${device.instanceId}", teamId = teamId, driverBuilder = driverBuilder ).validateAndUpdateDriver() } return SelectedDevice( platform = device.platform, device = device, deviceType = device.deviceType ) } if (isAndroid(host, port)) { val deviceType = when { deviceId?.startsWith("emulator") == true -> Device.DeviceType.EMULATOR else -> Device.DeviceType.REAL } return SelectedDevice( platform = Platform.ANDROID, host = host, port = port, deviceId = deviceId, deviceType = deviceType ) } return SelectedDevice( platform = Platform.IOS, host = null, port = null, deviceId = deviceId, deviceType = Device.DeviceType.SIMULATOR ) } private fun createMaestro( selectedDevice: SelectedDevice, connectToExistingSession: Boolean, isStudio: Boolean, isHeadless: Boolean, screenSize: String?, reinstallDriver: Boolean, driverHostPort: Int?, platformConfiguration: PlatformConfiguration? = null, ): MaestroSession { return when { selectedDevice.device != null -> MaestroSession( maestro = when (selectedDevice.device.platform) { Platform.ANDROID -> createAndroid( selectedDevice.device.instanceId, !connectToExistingSession, driverHostPort, reinstallDriver, ) Platform.IOS -> createIOS( selectedDevice.device.instanceId, !connectToExistingSession, driverHostPort, reinstallDriver, deviceType = selectedDevice.device.deviceType, platformConfiguration = platformConfiguration ) Platform.WEB -> pickWebDevice(isStudio, isHeadless, screenSize) }, device = selectedDevice.device, ) selectedDevice.platform == Platform.ANDROID -> MaestroSession( maestro = pickAndroidDevice( selectedDevice.host, selectedDevice.port, driverHostPort, !connectToExistingSession, reinstallDriver, selectedDevice.deviceId, ), device = null, ) selectedDevice.platform == Platform.IOS -> MaestroSession( maestro = pickIOSDevice( deviceId = selectedDevice.deviceId, openDriver = !connectToExistingSession, driverHostPort = driverHostPort ?: defaultXcTestPort, reinstallDriver = reinstallDriver, platformConfiguration = platformConfiguration, ), device = null, ) selectedDevice.platform == Platform.WEB -> MaestroSession( maestro = pickWebDevice(isStudio, isHeadless, screenSize), device = null ) else -> error("Unable to create Maestro session") } } private fun isAndroid(host: String?, port: Int?): Boolean { return try { val dadb = if (port != null) { Dadb.create(host ?: defaultHost, port) } else { Dadb.discover(host ?: defaultHost) ?: createAdbServerDadb() ?: error("No android devices found.") } dadb.close() true } catch (_: Exception) { false } } private fun pickAndroidDevice( host: String?, port: Int?, driverHostPort: Int?, openDriver: Boolean, reinstallDriver: Boolean, deviceId: String? = null, ): Maestro { val dadb = if (port != null) { Dadb.create(host ?: defaultHost, port) } else if (deviceId != null) { Dadb.list(host = host ?: defaultHost).find { it.toString() == deviceId } ?: error("No Android device found with id '$deviceId' on host '${host ?: defaultHost}'") } else { Dadb.discover(host ?: defaultHost) ?: createAdbServerDadb() ?: error("No android devices found.") } return Maestro.android( driver = AndroidDriver(dadb, driverHostPort, "", reinstallDriver), openDriver = openDriver, ) } private fun createAdbServerDadb(): Dadb? { return try { AdbServer.createDadb(adbServerPort = 5038) } catch (ignored: Exception) { null } } private fun pickIOSDevice( deviceId: String?, openDriver: Boolean, driverHostPort: Int, reinstallDriver: Boolean, platformConfiguration: PlatformConfiguration?, ): Maestro { val device = PickDeviceInteractor.pickDevice(deviceId, driverHostPort) return createIOS( device.instanceId, openDriver, driverHostPort, reinstallDriver, deviceType = device.deviceType, platformConfiguration = platformConfiguration ) } private fun createAndroid( instanceId: String, openDriver: Boolean, driverHostPort: Int?, reinstallDriver: Boolean, ): Maestro { val driver = AndroidDriver( dadb = Dadb .list() .find { it.toString() == instanceId } ?: Dadb.discover() ?: error("Unable to find device with id $instanceId"), hostPort = driverHostPort, emulatorName = instanceId, reinstallDriver = reinstallDriver, ) return Maestro.android( driver = driver, openDriver = openDriver, ) } private fun createIOS( deviceId: String, openDriver: Boolean, driverHostPort: Int?, reinstallDriver: Boolean, platformConfiguration: PlatformConfiguration?, deviceType: Device.DeviceType, ): Maestro { val iOSDeviceType = when (deviceType) { Device.DeviceType.REAL -> IOSDeviceType.REAL Device.DeviceType.SIMULATOR -> IOSDeviceType.SIMULATOR else -> { throw UnsupportedOperationException("Unsupported device type $deviceType for iOS platform") } } val iOSDriverConfig = when (deviceType) { Device.DeviceType.REAL -> { val maestroDirectory = Paths.get(System.getProperty("user.home"), ".maestro") val driverPath = maestroDirectory.resolve("maestro-iphoneos-driver-build").resolve("driver-iphoneos") .resolve("Build").resolve("Products") IOSDriverConfig( prebuiltRunner = false, sourceDirectory = driverPath.pathString, context = Context.CLI, snapshotKeyHonorModalViews = platformConfiguration?.ios?.snapshotKeyHonorModalViews ) } Device.DeviceType.SIMULATOR -> { IOSDriverConfig( prebuiltRunner = false, sourceDirectory = "driver-iPhoneSimulator", context = Context.CLI, snapshotKeyHonorModalViews = platformConfiguration?.ios?.snapshotKeyHonorModalViews ) } else -> throw UnsupportedOperationException("Unsupported device type $deviceType for iOS platform") } val tempFileHandler = TempFileHandler() val deviceController = when (deviceType) { Device.DeviceType.REAL -> { val device = util.LocalIOSDevice().listDeviceViaDeviceCtl(deviceId) val deviceCtlDevice = DeviceControlIOSDevice(deviceId = device.identifier) deviceCtlDevice } Device.DeviceType.SIMULATOR -> { val simctlIOSDevice = SimctlIOSDevice( deviceId = deviceId, tempFileHandler = tempFileHandler ) simctlIOSDevice } else -> throw UnsupportedOperationException("Unsupported device type $deviceType for iOS platform") } val xcTestInstaller = LocalXCTestInstaller( deviceId = deviceId, host = defaultXctestHost, defaultPort = driverHostPort ?: defaultXcTestPort, reinstallDriver = reinstallDriver, deviceType = iOSDeviceType, iOSDriverConfig = iOSDriverConfig, deviceController = deviceController, tempFileHandler = tempFileHandler ) val xcTestDriverClient = XCTestDriverClient( installer = xcTestInstaller, client = XCTestClient(defaultXctestHost, driverHostPort ?: defaultXcTestPort), reinstallDriver = reinstallDriver, ) val xcRunnerCLIUtils = XCRunnerCLIUtils(tempFileHandler = tempFileHandler) val xcTestDevice = XCTestIOSDevice( deviceId = deviceId, client = xcTestDriverClient, getInstalledApps = { xcRunnerCLIUtils.listApps(deviceId) }, ) val iosDriver = IOSDriver( LocalIOSDevice( deviceId = deviceId, xcTestDevice = xcTestDevice, deviceController = deviceController, insights = CliInsights ), insights = CliInsights ) return Maestro.ios( driver = iosDriver, openDriver = openDriver || xcTestDevice.isShutdown(), ) } private fun pickWebDevice(isStudio: Boolean, isHeadless: Boolean, screenSize: String?): Maestro { return Maestro.web(isStudio, isHeadless, screenSize) } private data class SelectedDevice( val platform: Platform, val device: Device.Connected? = null, val host: String? = null, val port: Int? = null, val deviceId: String? = null, val deviceType: Device.DeviceType, ) data class MaestroSession( val maestro: Maestro, val device: Device? = null, ) { fun close() { maestro.close() } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/session/SessionStore.kt ================================================ package maestro.cli.session import maestro.cli.db.KeyValueStore import maestro.device.Platform import java.nio.file.Paths import java.util.concurrent.TimeUnit object SessionStore { private val keyValueStore by lazy { KeyValueStore( dbFile = Paths .get(System.getProperty("user.home"), ".maestro", "sessions") .toFile() .also { it.parentFile.mkdirs() } ) } fun heartbeat(sessionId: String, platform: Platform) { synchronized(keyValueStore) { keyValueStore.set( key = key(sessionId, platform), value = System.currentTimeMillis().toString(), ) pruneInactiveSessions() } } private fun pruneInactiveSessions() { keyValueStore.keys() .forEach { key -> val lastHeartbeat = keyValueStore.get(key)?.toLongOrNull() if (lastHeartbeat != null && System.currentTimeMillis() - lastHeartbeat >= TimeUnit.SECONDS.toMillis(21)) { keyValueStore.delete(key) } } } fun delete(sessionId: String, platform: Platform) { synchronized(keyValueStore) { keyValueStore.delete( key(sessionId, platform) ) } } fun activeSessions(): List { synchronized(keyValueStore) { return keyValueStore .keys() .filter { key -> val lastHeartbeat = keyValueStore.get(key)?.toLongOrNull() lastHeartbeat != null && System.currentTimeMillis() - lastHeartbeat < TimeUnit.SECONDS.toMillis(21) } } } fun hasActiveSessions( sessionId: String, platform: Platform ): Boolean { synchronized(keyValueStore) { return activeSessions() .any { it != key(sessionId, platform) } } } private fun key(sessionId: String, platform: Platform): String { return "${platform}_$sessionId" } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/update/Updates.kt ================================================ package maestro.cli.update import maestro.cli.api.ApiClient import maestro.cli.api.CliVersion import maestro.cli.util.EnvUtils import maestro.cli.util.EnvUtils.CLI_VERSION import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import maestro.cli.util.ChangeLogUtils import maestro.cli.util.ChangeLog object Updates { private val DEFAULT_THREAD_FACTORY = Executors.defaultThreadFactory() private val EXECUTOR = Executors.newCachedThreadPool { DEFAULT_THREAD_FACTORY.newThread(it).apply { isDaemon = true } } private var future: CompletableFuture? = null private var changelogFuture: CompletableFuture>? = null fun fetchUpdatesAsync() { getFuture() } fun fetchChangelogAsync() { getChangelogFuture() } fun checkForUpdates(): CliVersion? { // Disable update check, when MAESTRO_DISABLE_UPDATE_CHECK is set to "true" e.g. when installed by a package manager. e.g. nix if (System.getenv("MAESTRO_DISABLE_UPDATE_CHECK")?.toBoolean() == true) { return null } return try { getFuture().get(3, TimeUnit.SECONDS) } catch (e: Exception) { return null } } fun getChangelog(): List? { // Disable update check, when MAESTRO_DISABLE_UPDATE_CHECK is set to "true" e.g. when installed by a package manager. e.g. nix if (System.getenv("MAESTRO_DISABLE_UPDATE_CHECK")?.toBoolean() == true) { return null } return try { getChangelogFuture().get(3, TimeUnit.SECONDS) } catch (e: Exception) { return null } } private fun fetchUpdates(): CliVersion? { if (CLI_VERSION == null) { return null } val latestCliVersion = ApiClient(EnvUtils.BASE_API_URL).getLatestCliVersion() return if (latestCliVersion > CLI_VERSION) { latestCliVersion } else { null } } private fun fetchChangelog(): ChangeLog { if (CLI_VERSION == null) { return null } val version = fetchUpdates()?.toString() ?: return null val content = ChangeLogUtils.fetchContent() return ChangeLogUtils.formatBody(content, version) } @Synchronized private fun getFuture(): CompletableFuture { var future = this.future if (future == null) { future = CompletableFuture.supplyAsync(this::fetchUpdates, EXECUTOR)!! this.future = future } return future } @Synchronized private fun getChangelogFuture(): CompletableFuture> { var changelogFuture = this.changelogFuture if (changelogFuture == null) { changelogFuture = CompletableFuture.supplyAsync(this::fetchChangelog, EXECUTOR)!! this.changelogFuture = changelogFuture } return changelogFuture } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt ================================================ package maestro.cli.util import maestro.cli.util.EnvUtils.CLI_VERSION import maestro.utils.HttpClient import okhttp3.Request import java.io.File typealias ChangeLog = List? object ChangeLogUtils { fun formatBody(content: String?, version: String): ChangeLog = content ?.split("\n## ")?.map { it.lines() } ?.firstOrNull { it.firstOrNull()?.startsWith(version) == true } ?.drop(1) ?.map { it.trim().replace("**", "") } ?.map { it.replace("\\[(.*?)]\\(.*?\\)".toRegex(), "$1") } ?.filter { it.isNotEmpty() && it.startsWith("- ") } fun fetchContent(): String? { val request = Request.Builder() .url("https://raw.githubusercontent.com/mobile-dev-inc/maestro/main/CHANGELOG.md") .build() return HttpClient.build("ChangeLogUtils").newCall(request).execute().body?.string() } fun print(changelog: ChangeLog): String = changelog?.let { "\n${it.joinToString("\n")}\n" }.orEmpty() } // Helper launcher to play around with presentation fun main() { val changelogFile = File(System.getProperty("user.dir"), "CHANGELOG.md") val content = changelogFile.readText() val unreleased = ChangeLogUtils.formatBody(content, "Unreleased") val current = ChangeLogUtils.formatBody(content, CLI_VERSION.toString()) val changelog = unreleased ?: current println("## ${unreleased?.let { "Unreleased" } ?: CLI_VERSION.toString()}") println("-".repeat(100)) println(ChangeLogUtils.print(changelog)) println("-".repeat(100)) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/CiUtils.kt ================================================ package maestro.cli.util object CiUtils { // When adding a new CI, also add the first version of Maestro that supports it. private val ciEnvVarMap = mapOf( "APPVEYOR" to "appveyor", // since v1.37.4 "BITBUCKET_BUILD_NUMBER" to "bitbucket", "BITRISE_IO" to "bitrise", "BUILDKITE" to "buildkite", // since v1.37.4 "CIRCLECI" to "circleci", "CIRRUS_CI" to "cirrusci", // since v1.37.4 "DRONE" to "drone", // since v1.37.4 "GITHUB_ACTIONS" to "github", "GITLAB_CI" to "gitlab", "JENKINS_HOME" to "jenkins", "TEAMCITY_VERSION" to "teamcity", // since v1.37.4 "CI" to "ci" ) private fun isTruthy(envVar: String?): Boolean { if (envVar == null) return false return envVar != "0" && envVar != "false" } fun getCiProvider(): String? { val mdevCiEnvVar = System.getenv("MDEV_CI") if (isTruthy(mdevCiEnvVar)) { return mdevCiEnvVar } for (ciEnvVar in ciEnvVarMap.entries) { try { if (isTruthy(System.getenv(ciEnvVar.key).lowercase())) return ciEnvVar.value } catch (e: Exception) { // We don't care } } return null } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/DependencyResolver.kt ================================================ package maestro.cli.util import maestro.orchestra.ApplyConfigurationCommand import maestro.orchestra.CompositeCommand import maestro.orchestra.MaestroCommand import maestro.orchestra.yaml.MaestroFlowParser import java.nio.file.Files import java.nio.file.Path import java.nio.file.LinkOption import kotlin.io.path.exists object DependencyResolver { fun discoverAllDependencies(flowFile: Path): List { val discoveredFiles = mutableSetOf() val filesToProcess = mutableListOf(normalizePath(flowFile)) while (filesToProcess.isNotEmpty()) { val currentFile = normalizePath(filesToProcess.removeFirst()) // Skip if we've already processed this file (prevents circular references) if (discoveredFiles.contains(currentFile)) continue // Add current file to discovered set discoveredFiles.add(currentFile) try { // Only process YAML files for dependency discovery if (!isYamlFile(currentFile)) { continue } val flowContent = Files.readString(currentFile) val commands = MaestroFlowParser.parseFlow(currentFile, flowContent) // Discover dependencies from each command val dependencies = commands.flatMap { maestroCommand -> extractDependenciesFromCommand(maestroCommand, currentFile) } val newDependencies = dependencies .map { normalizePath(it) } .filter { it.exists() && !discoveredFiles.contains(it) } filesToProcess.addAll(newDependencies) } catch (e: Exception) { // Ignore } } return discoveredFiles.toList() } private fun extractDependenciesFromCommand(maestroCommand: MaestroCommand, currentFile: Path): List { val commandDependencies = mutableListOf() // Check for runFlow commands and add the dependency if it exists (sourceDescription is not null) maestroCommand.runFlowCommand?.let { runFlow -> resolveDependencyFile(currentFile, runFlow.sourceDescription)?.let { commandDependencies.add(it) } } // Check for runScript commands and add the dependency if it exists (sourceDescription is not null) maestroCommand.runScriptCommand?.let { runScript -> resolveDependencyFile(currentFile, runScript.sourceDescription)?.let { commandDependencies.add(it) } } // Check for retry commands and add the dependency if it exists (sourceDescription is not null) maestroCommand.retryCommand?.let { retry -> resolveDependencyFile(currentFile, retry.sourceDescription)?.let { commandDependencies.add(it) } } // Check for assertScreenshot commands and add the reference image as a dependency maestroCommand.assertScreenshotCommand?.let { assertScreenshot -> resolveDependencyFile(currentFile, assertScreenshot.path)?.let { commandDependencies.add(it) } } // Check for addMedia commands and add the dependency if it exists (mediaPaths is not null) maestroCommand.addMediaCommand?.let { addMedia -> addMedia.mediaPaths.forEach { mediaPath -> resolveDependencyFile(currentFile, mediaPath)?.let { commandDependencies.add(it) } } } // Handle configuration commands (onFlowStart, onFlowComplete) maestroCommand.applyConfigurationCommand?.let { config -> config.config.onFlowStart?.commands?.forEach { startCommand -> commandDependencies.addAll(extractDependenciesFromCommand(startCommand, currentFile)) } config.config.onFlowComplete?.commands?.forEach { completeCommand -> commandDependencies.addAll(extractDependenciesFromCommand(completeCommand, currentFile)) } } // Handle ALL composite commands to extract dependencies from nested commands (RunFlow, Repeat, Retry) val command = maestroCommand.asCommand() if (command is CompositeCommand) { command.subCommands().forEach { nestedCommand -> commandDependencies.addAll(extractDependenciesFromCommand(nestedCommand, currentFile)) } } return commandDependencies } private fun resolvePath(flowPath: Path, requestedPath: String): Path { val path = flowPath.fileSystem.getPath(requestedPath) return if (path.isAbsolute) { path } else { flowPath.resolveSibling(path).toAbsolutePath() } } private fun resolveDependencyFile(currentFile: Path, requestedPath: String?): Path? { val trimmed = requestedPath?.trim() if (trimmed.isNullOrEmpty()) return null val resolved = resolvePath(currentFile, trimmed) return if (resolved.exists() && !Files.isDirectory(resolved)) resolved else null } private fun isYamlFile(path: Path): Boolean { val filename = path.fileName.toString().lowercase() return filename.endsWith(".yaml") || filename.endsWith(".yml") } private fun isJsFile(path: Path): Boolean { val filename = path.fileName.toString().lowercase() return filename.endsWith(".js") } fun getDependencySummary(flowFile: Path): String { val dependencies = discoverAllDependencies(flowFile) val mainFile = dependencies.firstOrNull { it == flowFile } val subflows = dependencies.filter { it != flowFile && isYamlFile(it) } val scripts = dependencies.filter { it != flowFile && isJsFile(it) } val otherFiles = dependencies.filter { it != flowFile && !isYamlFile(it) && !isJsFile(it) } return buildString { appendLine("Dependency discovery for: ${flowFile.fileName}") appendLine("Total files: ${dependencies.size}") if (subflows.isNotEmpty()) appendLine("Subflows: ${subflows.size}") if (scripts.isNotEmpty()) appendLine("Scripts: ${scripts.size}") if (otherFiles.isNotEmpty()) appendLine("Other files: ${otherFiles.size}") if (subflows.isNotEmpty()) { appendLine("Subflow files:") subflows.forEach { appendLine(" - ${it.fileName}") } } if (scripts.isNotEmpty()) { appendLine("Script files:") scripts.forEach { appendLine(" - ${it.fileName}") } } if (otherFiles.isNotEmpty()) { appendLine("Other files:") otherFiles.forEach { appendLine(" - ${it.fileName}") } } } } private fun normalizePath(path: Path): Path { return try { // Prefer canonical path without following symlinks path.toRealPath(LinkOption.NOFOLLOW_LINKS) } catch (e: Exception) { // Fall back to absolute normalized path if real path resolution fails path.toAbsolutePath().normalize() } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/EnvUtils.kt ================================================ package maestro.cli.util import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import maestro.cli.api.CliVersion import maestro.cli.update.Updates import maestro.cli.view.red import maestro.device.CPU_ARCHITECTURE import java.io.File import java.io.IOException import java.nio.file.Path import java.nio.file.Paths import java.util.Properties import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException object EnvUtils { private const val PROD_API_URL = "https://api.copilot.mobile.dev" val OS_NAME: String = System.getProperty("os.name") val OS_ARCH: String = System.getProperty("os.arch") val OS_VERSION: String = System.getProperty("os.version") val CLI_VERSION: CliVersion? = getCLIVersion() fun getVersion(): CliVersion? { return getCLIVersion().apply { if (this == null) { System.err.println("\nWarning: Failed to parse current version".red()) } } } val BASE_API_URL: String get() = System.getenv("MAESTRO_API_URL") ?: PROD_API_URL /** * Where Maestro config and state files were located before v1.37.0. */ fun legacyMaestroHome(): Path { return Paths.get(System.getProperty("user.home"), ".maestro") } fun xdgStateHome(): Path { if (System.getenv("XDG_STATE_HOME") != null) { return Paths.get(System.getenv("XDG_STATE_HOME"), "maestro") } return Paths.get(System.getProperty("user.home"), ".maestro") } /** * @return true, if we're executing from Windows Linux shell (WSL) */ fun isWSL(): Boolean { try { val p1 = ProcessBuilder("printenv", "WSL_DISTRO_NAME").start() if (!p1.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException() if (p1.exitValue() == 0 && String(p1.inputStream.readBytes()).trim().isNotEmpty()) { return true } val p2 = ProcessBuilder("printenv", "IS_WSL").start() if (!p2.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException() if (p2.exitValue() == 0 && String(p2.inputStream.readBytes()).trim().isNotEmpty()) { return true } } catch (ignore: Exception) { // ignore } return false } fun isWindows(): Boolean { return OS_NAME.lowercase().startsWith("windows") } /** * Returns major version of Java, e.g. 8, 11, 17, 21. */ fun getJavaVersion(): Int { // Adapted from https://stackoverflow.com/a/2591122/7009800 val version = System.getProperty("java.version") return if (version.startsWith("1.")) { version.substring(2, 3).toInt() } else { val dot = version.indexOf(".") if (dot != -1) version.substring(0, dot).toInt() else 0 } } fun getFlutterVersionAndChannel(): Pair { val stdout = try { runProcess( "flutter", "--no-version-check", "--version", "--machine", ).joinToString(separator = "") } catch (e: IOException) { // Flutter is probably not installed return Pair(first = null, second = null) } val mapper = jacksonObjectMapper() val version = runCatching { val obj: Map = mapper.readValue(stdout) obj["flutterVersion"].toString() } val channel = runCatching { val obj: Map = mapper.readValue(stdout) obj["channel"].toString() } return Pair(first = version.getOrNull(), second = channel.getOrNull()) } fun getMacOSArchitecture(): CPU_ARCHITECTURE { return determineArchitectureDetectionStrategy().detectArchitecture() } private fun determineArchitectureDetectionStrategy(): ArchitectureDetectionStrategy { return if (isWindows()) { ArchitectureDetectionStrategy.WindowsArchitectureDetection } else if (runProcess("uname").contains("Linux")) { ArchitectureDetectionStrategy.LinuxArchitectureDetection } else { ArchitectureDetectionStrategy.MacOsArchitectureDetection } } fun getCLIVersion(): CliVersion? { val props = try { Updates::class.java.classLoader.getResourceAsStream("version.properties").use { Properties().apply { load(it) } } } catch (e: Exception) { return null } val versionString = props["version"] as? String ?: return null return CliVersion.parse(versionString) } } sealed interface ArchitectureDetectionStrategy { fun detectArchitecture(): CPU_ARCHITECTURE object MacOsArchitectureDetection : ArchitectureDetectionStrategy { override fun detectArchitecture(): CPU_ARCHITECTURE { fun runSysctl(property: String) = runProcess("sysctl", property).any { it.endsWith(": 1") } // Prefer sysctl over 'uname -m' due to Rosetta making it unreliable val isArm64 = runSysctl("hw.optional.arm64") val isX86_64 = runSysctl("hw.optional.x86_64") return when { isArm64 -> CPU_ARCHITECTURE.ARM64 isX86_64 -> CPU_ARCHITECTURE.X86_64 else -> CPU_ARCHITECTURE.UNKNOWN } } } object LinuxArchitectureDetection : ArchitectureDetectionStrategy { override fun detectArchitecture(): CPU_ARCHITECTURE { return when (runProcess("uname", "-m").first()) { "x86_64" -> CPU_ARCHITECTURE.X86_64 "arm64" -> CPU_ARCHITECTURE.ARM64 else -> CPU_ARCHITECTURE.UNKNOWN } } } object WindowsArchitectureDetection: ArchitectureDetectionStrategy { override fun detectArchitecture(): CPU_ARCHITECTURE { return CPU_ARCHITECTURE.X86_64 } } } internal fun runProcess(program: String, vararg arguments: String): List { val process = ProcessBuilder(program, *arguments).start() return try { process.inputStream.reader().use { it.readLines().map(String::trim) } } catch (ignore: Exception) { emptyList() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/ErrorReporter.kt ================================================ package maestro.cli.util import maestro.cli.api.ApiClient import picocli.CommandLine import java.security.MessageDigest import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.Exception object ErrorReporter { private val executor = Executors.newCachedThreadPool { Executors.defaultThreadFactory().newThread(it).apply { isDaemon = true } } fun report(exception: Exception, parseResult: CommandLine.ParseResult) { val args = parseResult.expandedArgs() val scrubbedArgs = args.mapIndexed { idx, arg -> if (idx > 0 && args[idx - 1] in listOf("-e", "--env")) { val (key, value) = arg.split("=", limit = 1) key + "=" + hashString(value) } else arg } val task = executor.submit { ApiClient(EnvUtils.BASE_API_URL).sendErrorReport( exception, scrubbedArgs.joinToString(" ") ) } runCatching { task.get(1, TimeUnit.SECONDS) } } private fun hashString(input: String): String { return MessageDigest .getInstance("SHA-256") .digest(input.toByteArray()) .fold(StringBuilder()) { sb, it -> sb.append("%02x".format(it)) }.toString() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/FileDownloader.kt ================================================ package maestro.cli.util import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.request import io.ktor.client.request.url import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpMethod import io.ktor.http.contentLength import io.ktor.http.isSuccess import io.ktor.utils.io.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import java.io.File object FileDownloader { fun downloadFile( url: String, destination: File ): Flow { val httpClient = HttpClient(CIO) return flow { with(httpClient) { val response = request { url(url) method = HttpMethod.Get }.call.response val contentLength = response.contentLength() ?: error("Content length is null") val data = ByteArray(contentLength.toInt()) val bodyChannel = response.bodyAsChannel() var offset = 0 do { val currentRead = bodyChannel .readAvailable(data, offset, data.size) offset += currentRead val progress = offset / data.size.toFloat() emit(DownloadResult.Progress(progress)) } while (currentRead > 0) if (response.status.isSuccess()) { destination.writeBytes(data) emit(DownloadResult.Success) } else { emit(DownloadResult.Error("Network error. Response code: ${response.status}")) } } } } sealed class DownloadResult { object Success : DownloadResult() data class Error(val message: String, val cause: Exception? = null) : DownloadResult() data class Progress( val progress: Float ) : DownloadResult() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt ================================================ package maestro.cli.util import maestro.orchestra.yaml.YamlCommandReader import maestro.utils.StringUtils.toRegexSafe import java.io.File import java.util.zip.ZipInputStream object FileUtils { fun File.isZip(): Boolean { return try { ZipInputStream(inputStream()).close() true } catch (ignored: Exception) { false } } fun File.isWebFlow(): Boolean { if (isDirectory) { return listFiles() ?.any { it.isWebFlow() } ?: false } val isYaml = name.endsWith(".yaml", ignoreCase = true) || name.endsWith(".yml", ignoreCase = true) if ( !isYaml || name.equals("config.yaml", ignoreCase = true) || name.equals("config.yml", ignoreCase = true) ) { return false } val config = YamlCommandReader.readConfig(toPath()) return config.url != null } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/IOSEnvUtils.kt ================================================ package maestro.cli.util import java.io.IOException import kotlin.io.path.Path object IOSEnvUtils { val simulatorRuntimes: List get() { // See also: https://stackoverflow.com/a/78755176/7009800 val topLevelDirs = Path("/Library/Developer/CoreSimulator/Volumes").toFile() .listFiles() ?.filter { it.exists() } ?: emptyList() val installedRuntimes = topLevelDirs .map { it.resolve("Library/Developer/CoreSimulator/Profiles/Runtimes") } .map { it.listFiles() ?: emptyArray() } .reduceOrNull { acc, list -> acc + list } ?.map { file -> file.nameWithoutExtension } ?: emptyList() return installedRuntimes } val xcodeVersion: String? get() { val lines = try { runProcess("xcodebuild", "-version") } catch (e: IOException) { // Xcode toolchain is probably not installed return null } if (lines.size == 2 && lines.first().contains(' ')) { // Correct xcodebuild invocation is always 2 lines. Example: // $ xcodebuild -version // Xcode 15.4 // Build version 15F31d return lines.first().split(' ')[1] } return null } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/PrintUtils.kt ================================================ package maestro.cli.util import org.fusesource.jansi.Ansi import java.io.IOException import kotlin.system.exitProcess object PrintUtils { fun info(message: String, bold: Boolean = false, newline: Boolean = true) { val function: (Any) -> Unit = if (newline) ::println else ::print function( Ansi.ansi() .bold(apply = bold) .render(message) .boldOff() ) } fun message(message: String) { println(Ansi.ansi().render("@|cyan \n$message|@")) } fun prompt(message: String): String { print(Ansi.ansi().render("\n@|yellow,bold $message\n>|@")) try { return readln().trim() } catch (e: IOException) { exitProcess(1) } } fun success(message: String, bold: Boolean = false) { println( Ansi.ansi() .render("\n") .fgBrightGreen() .bold(apply = bold) .render(message) .boldOff() .fgDefault() ) } fun err(message: String, bold: Boolean = false) { println( Ansi.ansi() .render("\n") .fgRed() .bold(apply = bold) .render(message) .boldOff() .fgDefault() ) } fun warn(message: String, bold: Boolean = false) { println( Ansi.ansi() .render("\n") .fgYellow() .bold(apply = bold) .render(message) .boldOff() .fgDefault() ) } fun Ansi.bold(apply: Boolean = true): Ansi { return if (apply) { bold() } else { this } } fun clearConsole() { print("\u001b[H\u001b[2J") System.out.flush() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/ResourceUtils.kt ================================================ import kotlin.reflect.KClass fun readResourceAsText(cls: KClass<*>, path: String): String { val resourceStream = cls::class.java.getResourceAsStream(path) ?: throw IllegalStateException("Could not find $path in resources") return resourceStream.bufferedReader().use { it.readText() } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/ScreenReporter.kt ================================================ package maestro.cli.util import maestro.cli.api.ApiClient import maestro.utils.DepthTracker object ScreenReporter { fun reportMaxDepth() { val maxDepth = DepthTracker.getMaxDepth() if (maxDepth == 0) return ApiClient(EnvUtils.BASE_API_URL).sendScreenReport(maxDepth = maxDepth) } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/ScreenshotUtils.kt ================================================ package maestro.cli.util import java.io.File import maestro.Maestro import maestro.cli.report.FlowDebugOutput import maestro.cli.runner.CommandStatus import okio.Buffer import okio.sink object ScreenshotUtils { fun takeDebugScreenshot(maestro: Maestro, debugOutput: FlowDebugOutput, status: CommandStatus): File? { val containsFailed = debugOutput.screenshots.any { it.status == CommandStatus.FAILED } // Avoids duplicate failed images from parent commands if (containsFailed && status == CommandStatus.FAILED) { return null } val result = kotlin.runCatching { val out = File .createTempFile("screenshot-${System.currentTimeMillis()}", ".png") .also { it.deleteOnExit() } // save to another dir before exiting maestro.takeScreenshot(out.sink(), false) debugOutput.screenshots.add( FlowDebugOutput.Screenshot( screenshot = out, timestamp = System.currentTimeMillis(), status = status ) ) out } return result.getOrNull() } fun takeDebugScreenshotByCommand(maestro: Maestro, debugOutput: FlowDebugOutput, status: CommandStatus): File? { val result = kotlin.runCatching { val out = File .createTempFile("screenshot-${status}-${System.currentTimeMillis()}", ".png") .also { it.deleteOnExit() } // save to another dir before exiting maestro.takeScreenshot(out.sink(), false) debugOutput.screenshots.add( FlowDebugOutput.Screenshot( screenshot = out, timestamp = System.currentTimeMillis(), status = status ) ) out } return result.getOrNull() } fun writeAIscreenshot(buffer: Buffer): File { val out = File .createTempFile("ai-screenshot-${System.currentTimeMillis()}", ".png") .also { it.deleteOnExit() } out.outputStream().use { it.write(buffer.readByteArray()) } return out } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/SocketUtils.kt ================================================ package maestro.cli.util import java.net.ServerSocket fun getFreePort(): Int { (9999..11000).forEach { port -> try { ServerSocket(port).use { return it.localPort } } catch (ignore: Exception) {} } ServerSocket(0).use { return it.localPort } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/TimeUtils.kt ================================================ package maestro.cli.util import kotlin.math.roundToLong import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds object TimeUtils { fun durationInSeconds(startTimeInMillis: Long?, endTimeInMillis: Long?): Duration { if (startTimeInMillis == null || endTimeInMillis == null) return Duration.ZERO return ((endTimeInMillis - startTimeInMillis) / 1000f).roundToLong().seconds } fun durationInSeconds(durationInMillis: Long): Duration { return ((durationInMillis) / 1000f).roundToLong().seconds } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/Unpacker.kt ================================================ package maestro.cli.util import org.apache.commons.codec.digest.DigestUtils import java.io.File import java.net.URL import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Paths import java.nio.file.attribute.PosixFilePermission /** * Unpacks files from jar resources. */ object Unpacker { fun unpack( jarPath: String, target: File, ) { Unpacker::class.java.classLoader.getResource(jarPath)?.let { resource -> if (target.exists()) { if (sameContents(resource, target)) { return } } target.writeBytes(resource.readBytes()) } } private fun sameContents(resource: URL, target: File): Boolean { return DigestUtils.sha1Hex(resource.openStream()) == DigestUtils.sha1Hex(target.inputStream()) } fun binaryDependency(name: String): File { return Paths .get( System.getProperty("user.home"), ".maestro", "deps", name ) .toAbsolutePath() .toFile() .also { file -> createParentDirectories(file) createFileIfDoesNotExist(file) grantBinaryPermissions(file) } } private fun createParentDirectories(file: File) { file.parentFile?.let { parent -> if (!parent.exists()) { parent.mkdirs() } } } private fun createFileIfDoesNotExist(file: File) { if (!file.exists()) { if (!file.createNewFile()) { error("Unable to create file $file") } } } private fun grantBinaryPermissions(file: File) { if (isPosixFilesystem()) { Files.setPosixFilePermissions( file.toPath(), setOf( PosixFilePermission.OWNER_EXECUTE, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, ) ) } } private fun isPosixFilesystem() = FileSystems.getDefault() .supportedFileAttributeViews() .contains("posix") } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/WorkingDirectory.kt ================================================ package maestro.cli.util import java.io.File object WorkingDirectory { var baseDir: File = File(System.getProperty("user.dir")) fun resolve(path: String): File = File(baseDir, path) fun resolve(file: File): File = if (file.isAbsolute) file else File(baseDir, file.path) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/util/WorkspaceUtils.kt ================================================ package maestro.cli.util import java.io.FileNotFoundException import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.absolutePathString import kotlin.io.path.copyTo import kotlin.io.path.exists import kotlin.io.path.isDirectory import kotlin.streams.toList object WorkspaceUtils { fun createWorkspaceZip(file: Path, out: Path) { if (!file.exists()) throw FileNotFoundException(file.absolutePathString()) if (out.exists()) throw FileAlreadyExistsException(out.toFile()) val filesToInclude = if (!file.isDirectory()) { DependencyResolver.discoverAllDependencies(file) } else { Files.walk(file).filter { !it.isDirectory() }.toList() } val relativeTo = if (file.isDirectory()) file else file.parent createWorkspaceZipFromFiles(filesToInclude, relativeTo, out) } fun createWorkspaceZipFromFiles(files: List, relativeTo: Path, out: Path) { if (out.exists()) throw FileAlreadyExistsException(out.toFile()) val outUri = URI.create("jar:${out.toUri()}") FileSystems.newFileSystem(outUri, mapOf("create" to "true")).use { fs -> files.forEach { val outPath = fs.getPath(relativeTo.relativize(it).toString()) if (outPath.parent != null) { Files.createDirectories(outPath.parent) } it.copyTo(outPath) } } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/view/ErrorViewUtils.kt ================================================ package maestro.cli.view import maestro.MaestroException import maestro.orchestra.error.InvalidFlowFile import maestro.orchestra.error.NoInputException import maestro.orchestra.error.UnicodeNotSupportedError import maestro.orchestra.error.ValidationError import org.mozilla.javascript.EcmaError object ErrorViewUtils { fun exceptionToMessage(e: Exception): String { return when (e) { is ValidationError -> e.message is NoInputException -> "No commands found in Flow file" is InvalidFlowFile -> "Flow file is invalid: ${e.flowPath}" is UnicodeNotSupportedError -> "Unicode character input is not supported: ${e.text}. Please use ASCII characters. Follow the issue: https://github.com/mobile-dev-inc/maestro/issues/146" is InterruptedException -> "Interrupted" is MaestroException -> e.message is EcmaError -> "${e.name}: ${e.message}}" else -> e.stackTraceToString() } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/view/ProgressBar.kt ================================================ package maestro.cli.view import maestro.cli.DisableAnsiMixin import org.fusesource.jansi.Ansi class ProgressBar(private val width: Int) { private var progressWidth: Int? = null private var alreadyPrinted = 0 fun set(progress: Float) { if (DisableAnsiMixin.ansiEnabled) { val progressWidth = (progress * width).toInt() if (progressWidth == this.progressWidth) return this.progressWidth = progressWidth val ansi = Ansi.ansi() ansi.cursorToColumn(0) ansi.fgCyan() repeat(progressWidth) { ansi.a("█") } repeat(width - progressWidth) { ansi.a("░") } ansi.fgDefault() System.err.print(ansi) } else { val progressFactor = (progress * width).toInt() var amountToAdd = progressFactor - alreadyPrinted if (amountToAdd < 0) amountToAdd = 0 alreadyPrinted = progressFactor print(".".repeat(amountToAdd)) } } } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt ================================================ package maestro.cli.view 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 import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds object TestSuiteStatusView { fun showFlowCompletion(result: FlowResult) { val shardPrefix = result.shardIndex?.let { "[shard ${it + 1}] " }.orEmpty() print(Ansi.ansi().fgCyan().render(shardPrefix).fgDefault()) printStatus(result.status, result.cancellationReason) val durationString = result.duration?.let { " ($it)" }.orEmpty() print(" ${result.name}$durationString") if (result.status == FlowStatus.ERROR && result.error != null) { val error = " (${result.error})" print(Ansi.ansi().fgRed().render(error).fgDefault()) } else if (result.status == FlowStatus.WARNING) { val warning = " (Warning)" print(Ansi.ansi().fgYellow().render(warning).fgDefault()) } println() } fun showSuiteResult( suite: TestSuiteViewModel, uploadUrl: String, ) { val hasError = suite.flows.find { it.status == FlowStatus.ERROR } != null val canceledFlows = suite.flows .filter { it.status == FlowStatus.CANCELED } val shardPrefix = suite.shardIndex?.let { "[shard ${it + 1}] " }.orEmpty() if (suite.status == FlowStatus.ERROR || hasError) { val failedFlows = suite.flows .filter { it.status == FlowStatus.ERROR } PrintUtils.err( "${shardPrefix}${failedFlows.size}/${suite.flows.size} ${flowWord(failedFlows.size)} Failed", bold = true, ) if (canceledFlows.isNotEmpty()) { PrintUtils.warn("${shardPrefix}${canceledFlows.size} ${flowWord(canceledFlows.size)} Canceled") } } else { val passedFlows = suite.flows .filter { it.status == FlowStatus.SUCCESS || it.status == FlowStatus.WARNING } if (passedFlows.isNotEmpty()) { val durationMessage = suite.duration?.let { " in $it" } ?: "" PrintUtils.success( "${shardPrefix}${passedFlows.size}/${suite.flows.size} ${flowWord(passedFlows.size)} Passed$durationMessage", bold = true, ) if (canceledFlows.isNotEmpty()) { PrintUtils.warn("${shardPrefix}${canceledFlows.size} ${flowWord(canceledFlows.size)} Canceled") } } else { println() PrintUtils.err("${shardPrefix}All flows were canceled") } } println() if (suite.uploadDetails != null) { PrintUtils.info("==== View Details on Maestro Cloud ====") PrintUtils.info(uploadUrl.cyan()) println() } } private fun printStatus(status: FlowStatus, cancellationReason: UploadStatus.CancellationReason?) { val color = when (status) { FlowStatus.SUCCESS, FlowStatus.WARNING -> Ansi.Color.GREEN FlowStatus.ERROR -> Ansi.Color.RED FlowStatus.STOPPED -> Ansi.Color.RED else -> Ansi.Color.DEFAULT } val title = when (status) { FlowStatus.SUCCESS, FlowStatus.WARNING -> "Passed" FlowStatus.ERROR -> "Failed" FlowStatus.PENDING -> "Pending" FlowStatus.RUNNING -> "Running" FlowStatus.STOPPED -> "Stopped" FlowStatus.PREPARING -> "Preparing Device" FlowStatus.INSTALLING -> "Installing App" FlowStatus.CANCELED -> when (cancellationReason) { UploadStatus.CancellationReason.TIMEOUT -> "Timeout" UploadStatus.CancellationReason.OVERLAPPING_BENCHMARK -> "Skipped" UploadStatus.CancellationReason.BENCHMARK_DEPENDENCY_FAILED -> "Skipped" UploadStatus.CancellationReason.CANCELED_BY_USER -> "Canceled by user" UploadStatus.CancellationReason.RUN_EXPIRED -> "Run expired" else -> "Canceled (unknown reason)" } } print( Ansi.ansi() .fgBright(color) .render("[$title]") .fgDefault() ) } fun uploadUrl( projectId: String, appId: String, uploadId: String, domain: String = "" ): String { return if (domain.contains("localhost")) { "http://localhost:3000/project/$projectId/maestro-test/app/$appId/upload/$uploadId" } else { "https://app.maestro.dev/project/$projectId/maestro-test/app/$appId/upload/$uploadId" } } private fun flowWord(count: Int) = if (count == 1) "Flow" else "Flows" data class TestSuiteViewModel( val status: FlowStatus, val flows: List, val duration: Duration? = null, val shardIndex: Int? = null, val uploadDetails: UploadDetails? = null, ) { data class FlowResult( val name: String, val status: FlowStatus, val duration: Duration? = null, val error: String? = null, val shardIndex: Int? = null, val cancellationReason: UploadStatus.CancellationReason? = null ) data class UploadDetails( val uploadId: String, val appId: String, val domain: String, ) companion object { fun UploadStatus.toViewModel( uploadDetails: UploadDetails ) = TestSuiteViewModel( uploadDetails = uploadDetails, status = FlowStatus.from(status), flows = flows.map { it.toViewModel() } ) fun UploadStatus.FlowResult.toViewModel() = FlowResult( name = name, status = status, error = errors.firstOrNull(), cancellationReason = cancellationReason, duration = totalTime?.milliseconds ) } } } // Helper 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 = uploadDetails, status = FlowStatus.CANCELED, flows = listOf( FlowResult( name = "A", status = FlowStatus.SUCCESS, duration = 42.seconds, ), FlowResult( name = "B", status = FlowStatus.SUCCESS, duration = 231.seconds, ), FlowResult( name = "C", status = FlowStatus.CANCELED, ) ), duration = 273.seconds, ) status.flows .forEach { TestSuiteStatusView.showFlowCompletion(it) } val uploadUrl = uploadUrl( uploadDetails.uploadId.toString(), "teamid", uploadDetails.appId, uploadDetails.domain, ) TestSuiteStatusView.showSuiteResult(status, uploadUrl) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/view/ViewUtils.kt ================================================ package maestro.cli.view import org.fusesource.jansi.Ansi fun String.magenta(): String { return "@|magenta $this|@".render() } fun String.red(): String { return "@|red $this|@".render() } fun String.brightRed(): String { return "\u001B[91m$this\u001B[0m" } fun String.green(): String { return "@|green $this|@".render() } fun String.blue(): String { return "@|blue $this|@".render() } fun String.bold(): String { return "@|bold $this|@".render() } fun String.yellow(): String { return "@|yellow $this|@".render() } fun String.cyan(): String { return "@|cyan $this|@".render() } fun String.faint(): String { return "@|faint $this|@".render() } fun String.box(): String { return boxWithColor { it.magenta() } } fun String.greenBox(): String { return boxWithColor { it.green() } } private fun String.boxWithColor(colorize: (String) -> String): String { val lines = this.lines() val messageWidth = lines.map { it.replace(Regex("\u001B\\[[\\d;]*[^\\d;]"),"") }.maxOf { it.length } val paddingX = 3 val paddingY = 1 val width = messageWidth + paddingX * 2 val tl = colorize("╭") val tr = colorize("╮") val bl = colorize("╰") val br = colorize("╯") val hl = colorize("─") val vl = colorize("│") val py = "$vl${" ".repeat(width)}$vl\n".repeat(paddingY) val px = " ".repeat(paddingX) val l = "$vl$px" val r = "$px$vl" val sb = StringBuilder() sb.appendLine("$tl${hl.repeat(width)}$tr") sb.append(py) lines.forEach { line -> sb.appendLine("$l${padRight(line, messageWidth)}$r") } sb.append(py) sb.appendLine("$bl${hl.repeat(width)}$br") return sb.toString() } fun String.render(): String { return Ansi.ansi().render(this).toString() } private fun padRight(s: String, width: Int): String { // Strip ANSI escape sequences to compute the visible width val visible = s.replace(Regex("\u001B\\[[\\d;]*[^\\d;]"), "") val pad = (width - visible.length).coerceAtLeast(0) return s + " ".repeat(pad) } ================================================ FILE: maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt ================================================ 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 } } ================================================ FILE: maestro-cli/src/main/resources/ai_report.css ================================================ @layer components { body { @apply dark:bg-gray-dark dark:text-gray-1 text-gray-dark; } .screenshot-image { @apply w-64 rounded-lg border-2 border-gray-medium dark:border-gray-1 pb-1; } .screen-card { @apply flex items-start gap-4; } .defect-card { @apply flex flex-col items-start gap-2 rounded-lg bg-[#f8f8f8] p-2 text-gray-dark dark:bg-gray-medium dark:text-gray-1; } .badge { @apply dark:text-red-500 rounded-lg bg-[#ececec] dark:bg-gray-dark p-1 font-semibold text-gray-medium dark:text-gray-1; } .toggle-link { @apply block border-2 border-gray-medium bg-[#ececec] px-3 py-4 text-gray-medium hover:bg-gray-medium hover:text-[#ececec]; } .toggle-link-selected { @apply border-orange-2; } .divider { @apply h-0.5 rounded-sm bg-gray-medium dark:bg-gray-1 my-2; } .btn { @apply hover:text-gray-medium dark:hover:text-gray-medium; } } ================================================ FILE: maestro-cli/src/main/resources/html-detailed.css ================================================ .step-item { border-left: 3px solid #dee2e6; padding-left: 12px; padding-top: 8px; padding-bottom: 8px; } .step-header { font-family: monospace; font-size: 14px; } .step-name { font-weight: 500; } ================================================ FILE: maestro-cli/src/main/resources/logback-test.xml ================================================ [%-5level] %logger{36} - %msg%n ================================================ FILE: maestro-cli/src/main/resources/tailwind.config.js ================================================ tailwind.config = { darkMode: "media", theme: { extend: { colors: { "gray-dark": "#110c22", // text-gray-dark "gray-medium": "#4f4b5c", // text-gray-medium "gray-1": "#f8f8f8", // surface-gray-1 "gray-0": "#110C22", // surface-gray-0 "orange-2": "#ff9254", // surface-orange-2 }, }, }, }; ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/android/AndroidDeviceProvider.kt ================================================ package maestro.cli.android import dadb.Dadb import dadb.adbserver.AdbServer class AndroidDeviceProvider { fun local(): Dadb { val dadb = AdbServer.createDadb(connectTimeout = 60_000, socketTimeout = 60_000) return dadb } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/cloud/CloudInteractorTest.kt ================================================ package maestro.cli.cloud import com.google.common.truth.Truth.assertThat import io.mockk.* import maestro.cli.CliError import maestro.cli.api.ApiClient import maestro.cli.api.AppBinaryInfo import maestro.cli.api.DeviceConfiguration import maestro.cli.api.UploadResponse import maestro.cli.api.UploadStatus import maestro.cli.auth.Auth import maestro.cli.model.FlowStatus import maestro.cli.report.ReportFormat import maestro.orchestra.validation.AppMetadataAnalyzer import maestro.orchestra.validation.WorkspaceValidator import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir import java.io.ByteArrayOutputStream import java.io.File import java.io.PrintStream import java.util.concurrent.TimeUnit class CloudInteractorTest { private lateinit var mockApiClient: ApiClient private lateinit var mockAuth: Auth private lateinit var originalOut: PrintStream private lateinit var outputStream: ByteArrayOutputStream @TempDir lateinit var tempDir: File @BeforeEach fun setUp() { mockApiClient = mockk(relaxed = true) mockAuth = mockk(relaxed = true) every { mockAuth.getAuthToken(any(), any()) } returns "test-token" every { mockApiClient.getProjects(any()) } returns listOf( maestro.cli.api.ProjectResponse(id = "proj_1", name = "Test Project") ) every { mockApiClient.listCloudDevices() } returns mapOf( "android" to mapOf("pixel_6" to listOf("android-34", "android-33", "android-31", "android-30", "android-29")), "ios" to mapOf( "iPhone-11" to listOf("iOS-16-2", "iOS-17-5", "iOS-18-2"), "iPhone-14" to listOf("iOS-16-2", "iOS-17-5", "iOS-18-2"), ), "web" to mapOf("chromium" to listOf("default")), ) // Capture console output originalOut = System.out outputStream = ByteArrayOutputStream() System.setOut(PrintStream(outputStream)) } @AfterEach fun tearDown() { System.setOut(originalOut) } // ---- Fixtures from test resources ---- private fun resourceFile(path: String): File = File(javaClass.getResource(path)!!.toURI()) private fun androidFlowFile(): File = resourceFile("/workspaces/cloud_test/android/flow.yaml") private fun iosFlowFile(): File = resourceFile("/workspaces/cloud_test/ios/flow.yaml") private fun webFlowFile(): File = resourceFile("/workspaces/cloud_test/web/flow.yaml") private fun taggedFlowDir(): File = resourceFile("/workspaces/cloud_test/tagged") private fun iosApp(): File = resourceFile("/apps/test-ios.zip") private fun webManifest(): File = resourceFile("/apps/web-manifest.json") /** Creates a flow file with a custom appId in tempDir (for mismatch / error tests). */ private fun createFlowFile(appId: String): File { return File(tempDir, "flow.yaml").also { it.writeText("appId: $appId\n---\n- launchApp\n") } } private fun stubUploadResponse( platform: String = "Android", appBinaryId: String? = null, ) { every { mockApiClient.upload( authToken = any(), appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = any(), repoName = any(), branch = any(), commitSha = any(), pullRequestId = any(), env = any(), appBinaryId = any(), includeTags = any(), excludeTags = any(), disableNotifications = any(), deviceLocale = any(), progressListener = any(), projectId = any(), deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } returns UploadResponse( orgId = "org_1", uploadId = "upload_1", appId = "app_1", deviceConfiguration = DeviceConfiguration( platform = platform, deviceName = "Test Device", orientation = "portrait", osVersion = "33", displayInfo = "Test Device", deviceLocale = "en_US", ), appBinaryId = appBinaryId, ) // Stub the upload status for async=true (not polled) every { mockApiClient.uploadStatus(any(), any(), any()) } returns UploadStatus( uploadId = "upload_1", status = UploadStatus.Status.SUCCESS, completed = true, totalTime = 30L, startTime = 0L, flows = emptyList(), appPackageId = null, wasAppLaunched = false, ) } private fun createCloudInteractor( webManifestProvider: (() -> File?)? = null, ): CloudInteractor { return CloudInteractor( client = mockApiClient, appFileValidator = { AppMetadataAnalyzer.validateAppFile(it) }, workspaceValidator = WorkspaceValidator(), webManifestProvider = webManifestProvider, auth = mockAuth, waitTimeoutMs = TimeUnit.SECONDS.toMillis(1), minPollIntervalMs = TimeUnit.MILLISECONDS.toMillis(10), maxPollingRetries = 2, failOnTimeout = true, ) } // ---- 1. iOS .app + matching workspace (happy path) ---- @Test fun `upload with iOS app file and matching workspace succeeds`() { stubUploadResponse(platform = "IOS") val result = createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = iosApp(), async = true, projectId = "proj_1", ) assertThat(result).isEqualTo(0) verify { mockApiClient.upload( authToken = "test-token", appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = any(), repoName = any(), branch = any(), commitSha = any(), pullRequestId = any(), env = any(), appBinaryId = isNull(), includeTags = any(), excludeTags = any(), disableNotifications = any(), deviceLocale = any(), progressListener = any(), projectId = "proj_1", deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } } // ---- 2. Web flow (no app file) ---- @Test fun `upload with web flow and no app file succeeds`() { stubUploadResponse(platform = "WEB") val result = createCloudInteractor(webManifestProvider = { webManifest() }).upload( flowFile = webFlowFile(), appFile = null, async = true, projectId = "proj_1", ) assertThat(result).isEqualTo(0) } // ---- 3. --app-binary-id Android ---- @Test fun `upload with Android appBinaryId resolves platform from server`() { stubUploadResponse(platform = "Android", appBinaryId = "bin_android_1") every { mockApiClient.getAppBinaryInfo("test-token", "bin_android_1") } returns AppBinaryInfo( appBinaryId = "bin_android_1", platform = "Android", appId = "com.example.maestro.orientation", ) val result = createCloudInteractor().upload( flowFile = androidFlowFile(), appFile = null, async = true, appBinaryId = "bin_android_1", projectId = "proj_1", ) assertThat(result).isEqualTo(0) verify(exactly = 1) { mockApiClient.getAppBinaryInfo("test-token", "bin_android_1") } } // ---- 4. --app-binary-id iOS ---- @Test fun `upload with iOS appBinaryId resolves platform from server`() { stubUploadResponse(platform = "IOS", appBinaryId = "bin_ios_1") every { mockApiClient.getAppBinaryInfo("test-token", "bin_ios_1") } returns AppBinaryInfo( appBinaryId = "bin_ios_1", platform = "iOS", appId = "com.example.SimpleWebViewApp", ) val result = createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = null, async = true, appBinaryId = "bin_ios_1", projectId = "proj_1", ) assertThat(result).isEqualTo(0) verify(exactly = 1) { mockApiClient.getAppBinaryInfo("test-token", "bin_ios_1") } } // ---- 5. Missing app file + no binary ID + not web ---- @Test fun `upload throws CliError when no app file, no binary id, and not web flow`() { val error = assertThrows { createCloudInteractor().upload( flowFile = androidFlowFile(), appFile = null, async = true, projectId = "proj_1", ) } assertThat(error.message).contains("Missing required parameter") } // ---- 6. Workspace with no matching flows ---- @Test fun `upload throws CliError when workspace flows do not match app id`() { // Flow has appId=com.example.SimpleWebViewApp but we tell the server the app is "com.different.app" val flowFile = createFlowFile("com.nonexistent.app") val error = assertThrows { createCloudInteractor().upload( flowFile = flowFile, appFile = iosApp(), async = true, projectId = "proj_1", ) } assertThat(error.message).contains("No flows in workspace match app ID") } // ---- 7. --app-binary-id not found (404) ---- @Test fun `upload throws CliError when appBinaryId not found on server`() { every { mockApiClient.getAppBinaryInfo("test-token", "nonexistent") } throws ApiClient.ApiException(404) val error = assertThrows { createCloudInteractor().upload( flowFile = androidFlowFile(), appFile = null, async = true, appBinaryId = "nonexistent", projectId = "proj_1", ) } assertThat(error.message).contains("not found") } // ---- 8. --app-binary-id server error ---- @Test fun `upload throws CliError when server returns error for appBinaryId`() { every { mockApiClient.getAppBinaryInfo("test-token", "bin_err") } throws ApiClient.ApiException(500) val error = assertThrows { createCloudInteractor().upload( flowFile = androidFlowFile(), appFile = null, async = true, appBinaryId = "bin_err", projectId = "proj_1", ) } assertThat(error.message).contains("Failed to fetch app binary info") } // ---- 9. --device-locale passed through ---- @Test fun `upload passes device locale to api client`() { stubUploadResponse(platform = "IOS") createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = iosApp(), async = true, deviceLocale = "fr_FR", projectId = "proj_1", ) verify { mockApiClient.upload( authToken = any(), appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = any(), repoName = any(), branch = any(), commitSha = any(), pullRequestId = any(), env = any(), appBinaryId = any(), includeTags = any(), excludeTags = any(), disableNotifications = any(), deviceLocale = eq("fr_FR"), progressListener = any(), projectId = any(), deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } } // ---- 10. --include-tags passed through ---- @Test fun `upload passes include tags to workspace validation and api client`() { stubUploadResponse(platform = "IOS") createCloudInteractor().upload( flowFile = taggedFlowDir(), appFile = iosApp(), async = true, includeTags = listOf("smoke"), projectId = "proj_1", ) verify { mockApiClient.upload( authToken = any(), appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = any(), repoName = any(), branch = any(), commitSha = any(), pullRequestId = any(), env = any(), appBinaryId = any(), includeTags = eq(listOf("smoke")), excludeTags = any(), disableNotifications = any(), deviceLocale = any(), progressListener = any(), projectId = any(), deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } } // ---- 11. Unsupported platform from server ---- @Test fun `upload throws CliError when server returns unsupported platform for appBinaryId`() { every { mockApiClient.getAppBinaryInfo("test-token", "bin_symbian") } returns AppBinaryInfo( appBinaryId = "bin_symbian", platform = "Symbian", appId = "com.example.app", ) val error = assertThrows { createCloudInteractor().upload( flowFile = androidFlowFile(), appFile = null, async = true, appBinaryId = "bin_symbian", projectId = "proj_1", ) } assertThat(error.message).contains("Unsupported platform") } // ---- 12. CI metadata passed through ---- @Test fun `upload passes CI metadata to api client`() { stubUploadResponse(platform = "IOS") createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = iosApp(), async = true, repoOwner = "acme", repoName = "app", branch = "feature/x", commitSha = "abc123", pullRequestId = "42", projectId = "proj_1", ) verify { mockApiClient.upload( authToken = any(), appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = eq("acme"), repoName = eq("app"), branch = eq("feature/x"), commitSha = eq("abc123"), pullRequestId = eq("42"), env = any(), appBinaryId = any(), includeTags = any(), excludeTags = any(), disableNotifications = any(), deviceLocale = any(), progressListener = any(), projectId = any(), deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } } // ---- 13. Env vars passed through ---- @Test fun `upload passes env vars to api client`() { stubUploadResponse(platform = "IOS") createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = iosApp(), async = true, env = mapOf("API_KEY" to "secret"), projectId = "proj_1", ) verify { mockApiClient.upload( authToken = any(), appFile = any(), workspaceZip = any(), uploadName = any(), mappingFile = any(), repoOwner = any(), repoName = any(), branch = any(), commitSha = any(), pullRequestId = any(), env = eq(mapOf("API_KEY" to "secret")), appBinaryId = any(), includeTags = any(), excludeTags = any(), disableNotifications = any(), deviceLocale = any(), progressListener = any(), projectId = any(), deviceModel = any(), deviceOs = any(), androidApiLevel = any(), iOSVersion = any(), ) } } // ---- 16. Valid device config and compatible app succeeds ---- @Test fun `upload with valid device config and compatible app succeeds`() { stubUploadResponse(platform = "IOS") val result = createCloudInteractor().upload( flowFile = iosFlowFile(), appFile = iosApp(), async = true, projectId = "proj_1", deviceModel = "iPhone-14", deviceOs = "iOS-18-2", ) assertThat(result).isEqualTo(0) } // ---- waitForCompletion tests (existing) ---- @Test fun `waitForCompletion should return 0 when upload completes successfully`() { val uploadStatus = createUploadStatus( completed = true, status = UploadStatus.Status.SUCCESS, startTime = 0L, totalTime = 30L, flows = listOf( createFlowResult("flow1", FlowStatus.SUCCESS, 0L, 50L), createFlowResult("flow2", FlowStatus.SUCCESS, 0L, 50L) ) ) every { mockApiClient.uploadStatus(any(), any(), any()) } returns uploadStatus val result = createCloudInteractor().waitForCompletion( authToken = "token", uploadId = "upload123", appId = "app123", failOnCancellation = false, reportFormat = ReportFormat.NOOP, reportOutput = null, testSuiteName = null, uploadUrl = "http://example.com", projectId = "project123" ) assertThat(result.status).isEqualTo(UploadStatus.Status.SUCCESS) verify(exactly = 1) { mockApiClient.uploadStatus("token", "upload123", "project123") } val output = outputStream.toString() val cleanOutput = output.replace(Regex("\\u001B\\[[;\\d]*m"), "") assertThat(cleanOutput).contains("[Passed] flow1 (50ms)") assertThat(cleanOutput).contains("[Passed] flow2 (50ms)") assertThat(cleanOutput).contains("2/2 Flows Passed") assertThat(cleanOutput).contains("Process will exit with code 0 (SUCCESS)") assertThat(cleanOutput).contains("http://example.com") val flow1Occurrences = cleanOutput.split("[Passed] flow1 (50ms)").size - 1 val flow2Occurrences = cleanOutput.split("[Passed] flow2 (50ms)").size - 1 assertThat(flow1Occurrences).isEqualTo(1) assertThat(flow2Occurrences).isEqualTo(1) } @Test fun `waitForCompletion should handle status changes and eventually complete`() { val initialStatus = createUploadStatus( completed = false, status = UploadStatus.Status.RUNNING, startTime = 0L, totalTime = null, flows = listOf( createFlowResult("flow1", FlowStatus.RUNNING, 0L, null), createFlowResult("flow2", FlowStatus.RUNNING, 0L, null), createFlowResult("flow3", FlowStatus.PENDING, 0L, null) ) ) val intermediateStatus = createUploadStatus( completed = false, status = UploadStatus.Status.RUNNING, startTime = 0L, totalTime = null, flows = listOf( createFlowResult("flow1", FlowStatus.SUCCESS, 0L, 45L), createFlowResult("flow2", FlowStatus.RUNNING, 0L, null), createFlowResult("flow3", FlowStatus.RUNNING, 0L, null) ) ) val finalStatus = createUploadStatus( completed = true, status = UploadStatus.Status.SUCCESS, startTime = 0L, totalTime = 60L, flows = listOf( createFlowResult("flow1", FlowStatus.SUCCESS, 0L, 45L), createFlowResult("flow2", FlowStatus.ERROR, 0L, 60L), createFlowResult("flow3", FlowStatus.STOPPED, 0L, null) ) ) every { mockApiClient.uploadStatus(any(), any(), any()) } returnsMany listOf( initialStatus, initialStatus, intermediateStatus, intermediateStatus, intermediateStatus, finalStatus ) val result = createCloudInteractor().waitForCompletion( authToken = "token", uploadId = "upload123", appId = "app123", failOnCancellation = false, reportFormat = ReportFormat.NOOP, reportOutput = null, testSuiteName = null, uploadUrl = "http://example.com", projectId = "project123" ) assertThat(result.status).isEqualTo(UploadStatus.Status.SUCCESS) verify(exactly = 6) { mockApiClient.uploadStatus("token", "upload123", "project123") } val output = outputStream.toString() val cleanOutput = output.replace(Regex("\\u001B\\[[;\\d]*m"), "") assertThat(cleanOutput).contains("[Passed] flow1 (45ms)") assertThat(cleanOutput).contains("[Failed] flow2 (60ms)") assertThat(cleanOutput).contains("[Stopped] flow3") assertThat(cleanOutput).contains("1/3 Flow Failed") assertThat(cleanOutput).contains("Process will exit with code 1 (FAIL)") assertThat(cleanOutput).contains("http://example.com") val flow1Occurrences = cleanOutput.split("[Passed] flow1 (45ms)").size - 1 val flow2Occurrences = cleanOutput.split("[Failed] flow2 (60ms)").size - 1 assertThat(flow1Occurrences).isEqualTo(1) assertThat(flow2Occurrences).isEqualTo(1) } // ---- Helpers ---- private fun createUploadStatus(completed: Boolean, status: UploadStatus.Status, flows: List, startTime: Long?, totalTime: Long?): UploadStatus { return UploadStatus( uploadId = "upload123", status = status, completed = completed, flows = flows, totalTime = totalTime, startTime = startTime, appPackageId = null, wasAppLaunched = false, ) } private fun createFlowResult(name: String, status: FlowStatus, startTime: Long = 0L, totalTime: Long?): UploadStatus.FlowResult { return UploadStatus.FlowResult( name = name, status = status, errors = emptyList(), startTime = startTime, totalTime = totalTime ) } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/command/TestCommandTest.kt ================================================ package maestro.cli.command import com.google.common.truth.Truth.assertThat import maestro.orchestra.workspace.WorkspaceExecutionPlanner import maestro.orchestra.WorkspaceConfig import org.junit.jupiter.api.Test import org.junit.jupiter.api.BeforeEach import java.nio.file.Path class TestCommandTest { private lateinit var testCommand: TestCommand @BeforeEach fun setUp() { testCommand = TestCommand() } /***************************************** *** executionPlanIncludesWebFlow Tests *** ******************************************/ @Test fun `executionPlanIncludesWebFlow should return false when both flowsToRun and sequence flows are empty`() { val executionPlan = WorkspaceExecutionPlanner.ExecutionPlan( flowsToRun = emptyList(), sequence = WorkspaceExecutionPlanner.FlowSequence(emptyList(), true), workspaceConfig = WorkspaceConfig() ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `executionPlanIncludesWebFlow should return true when flowsToRun contains both mobile & web flow`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/00_mixed_web_mobile_flow_tests") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val includesWebFlow = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(includesWebFlow).isTrue() } @Test fun `executionPlanIncludesWebFlow should return true when sequence flows contains web flow only`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/01_web_only") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isTrue() } @Test fun `executionPlanIncludesWebFlow should return false when no web flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/02_mobile_only") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `executionPlanIncludesWebFlow should return true if after config mixed flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/03_mixed_with_config_execution_order") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isTrue() } @Test fun `executionPlanIncludesWebFlow should return false if after config no web flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/04_web_only_with_config_execution_order") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isFalse() } /***************************************** ******** allFlowsAreWebFlow Tests ******** ******************************************/ @Test fun `allFlowsAreWebFlow should return false when both flowsToRun and sequence flows are empty`() { val executionPlan = WorkspaceExecutionPlanner.ExecutionPlan( flowsToRun = emptyList(), sequence = WorkspaceExecutionPlanner.FlowSequence(emptyList(), true), workspaceConfig = WorkspaceConfig() ) val result = testCommand.allFlowsAreWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `allFlowsAreWebFlow should return false when flowsToRun contains both mobile & web flow`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/00_mixed_web_mobile_flow_tests") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.allFlowsAreWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `allFlowsAreWebFlow should return true when sequence flows contains web flow only`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/01_web_only") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.allFlowsAreWebFlow(executionPlan) assertThat(result).isTrue() } @Test fun `allFlowsAreWebFlow should return false when no web flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/02_mobile_only") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.allFlowsAreWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `allFlowsAreWebFlow should return false if after config mixed flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/03_mixed_with_config_execution_order") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.allFlowsAreWebFlow(executionPlan) assertThat(result).isFalse() } @Test fun `allFlowsAreWebFlow should return false if after config no web flows exist`() { val workspacePath = getTestResourcePath("workspaces/test_command_test/04_web_only_with_config_execution_order") val executionPlan = WorkspaceExecutionPlanner.plan( input = setOf(workspacePath), includeTags = emptyList(), excludeTags = emptyList(), config = null ) val result = testCommand.executionPlanIncludesWebFlow(executionPlan) assertThat(result).isFalse() } /***************************************** ************ Common Functions ************ ******************************************/ private fun getTestResourcePath(resourcePath: String): Path { val resourceUrl = javaClass.classLoader.getResource(resourcePath) requireNotNull(resourceUrl) { "Test resource not found: $resourcePath" } return Path.of(resourceUrl.toURI()) } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/driver/DriverBuilderTest.kt ================================================ package maestro.cli.driver import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk import io.mockk.slot import org.junit.jupiter.api.Assertions.* import io.mockk.spyk import maestro.cli.api.CliVersion import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.TimeUnit import kotlin.io.path.exists import kotlin.io.path.pathString import kotlin.io.path.readText class DriverBuilderTest { @TempDir lateinit var tempDir: Path @Test fun `test if driver is built successfully and written in directory`() { // given val mockProcess = mockk(relaxed = true) val mockProcessBuilderFactory = mockk() val sourceCodeRoot = System.getenv("GITHUB_WORKSPACE") ?: System.getProperty("user.home") every { mockProcess.waitFor(120, TimeUnit.SECONDS) } returns true // Simulate successful execution every { mockProcess.exitValue() } returns 0 every { mockProcessBuilderFactory.createProcess(any(), any(), any()) } answers { val derivedDataPath = Files.createDirectories( Paths.get(sourceCodeRoot , ".maestro", "maestro-iphoneos-driver-build", "driver-iphoneos", "Build", "Products") ) val debugIphoneDir = Files.createDirectories(Paths.get(derivedDataPath.pathString, "Debug-iphoneos")) // Simulate creating build products File(derivedDataPath.toFile(), "maestro-driver-ios-config.xctestrun").writeText("Fake Runner xctestrun file") File(debugIphoneDir.toFile(), "maestro-driver-iosUITests-Runner.app").writeText("Fake Runner App Content") File(debugIphoneDir.toFile(), "maestro-driver-ios.app").writeText("Fake iOS Driver App Content") println("Simulated build process: Build products created in derived data path.") mockProcess // Return the mocked process } // when val builder = DriverBuilder(mockProcessBuilderFactory) val buildProducts = builder.buildDriver( DriverBuildConfig( teamId = "25CQD4CKK3", derivedDataPath = "driver-iphoneos", sourceCodePath = "driver/ios", sourceCodeRoot = System.getenv("GITHUB_WORKSPACE") ?: System.getProperty("user.home"), cliVersion = CliVersion(1, 40, 0) ) ) val xctestRunFile = buildProducts.toFile().walk().firstOrNull { it.extension == "xctestrun" } val appDir = buildProducts.resolve("Debug-iphoneos/maestro-driver-ios.app") val runnerDir = buildProducts.resolve("Debug-iphoneos/maestro-driver-iosUITests-Runner.app") // then assertThat(xctestRunFile?.exists()).isTrue() assertThat(appDir.exists()).isTrue() assertThat(runnerDir.exists()).isTrue() Paths.get(System.getenv("GITHUB_WORKSPACE") ?: System.getProperty("user.home"), "maestro-iphoneos-driver-build").toFile().deleteRecursively() } @Test fun `should write error output to file inside _maestro on build failure`() { // given val sourceCodeRoot = System.getenv("GITHUB_WORKSPACE") ?: System.getProperty("user.home") val driverBuildConfig = mockk() val processBuilderFactory = mockk() val driverBuilder = spyk(DriverBuilder(processBuilderFactory)) val mockProcess = mockk(relaxed = true) val capturedFileSlot = slot() every { driverBuildConfig.sourceCodePath } returns "mock/source" every { driverBuildConfig.sourceCodeRoot } returns sourceCodeRoot every { driverBuildConfig.derivedDataPath } returns "mock/source" every { driverBuildConfig.teamId } returns "mock-team-id" every { driverBuildConfig.architectures } returns "arm64" every { driverBuildConfig.destination } returns "generic/platform=ios" every { driverBuildConfig.cliVersion } returns CliVersion.parse("1.40.0") every { driverBuilder.getDriverSourceFromResources(any()) } returns tempDir every { mockProcess.exitValue() } returns 1 every { mockProcess.waitFor(120, TimeUnit.SECONDS) } returns true every { processBuilderFactory.createProcess(commands = any(), workingDirectory = any(), outputFile = capture(capturedFileSlot)) } answers { capturedFileSlot.captured.writeText("xcodebuild failed!") mockProcess } // when val error = assertThrows(RuntimeException::class.java) { driverBuilder.buildDriver(driverBuildConfig) } // then assertThat(error.message).contains("Failed to build iOS driver for connected iOS device") // Verify that the error log has been written inside the `.maestro` directory val maestroDir = Paths.get(sourceCodeRoot, ".maestro") val errorLog = maestroDir.resolve("maestro-iphoneos-driver-build").resolve("output.log") // Verify file exists and contains error output assertTrue(Files.exists(errorLog), "Expected an error log file to be written.") assertTrue(errorLog.readText().contains("xcodebuild failed!"), "Log should contain build failure message.") Paths.get(System.getenv("GITHUB_WORKSPACE") ?: System.getProperty("user.home"), "maestro-iphoneos-driver-build").toFile().deleteRecursively() } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/driver/RealDeviceDriverTest.kt ================================================ package maestro.cli.driver import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.verify import maestro.cli.api.CliVersion import maestro.cli.util.EnvUtils import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.util.* import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.pathString import kotlin.io.path.writeText class RealDeviceDriverTest { @TempDir lateinit var tempDir: Path // Temporary directory for test isolation @Test fun `should update driver when version is outdated`() { // Set up a test version.properties file with an outdated version // Mock CLI_VERSION in EnvUtils to return "1.2.0" mockkObject(EnvUtils) val driverBuilder = mockk() every { driverBuilder.buildDriver(any()) } returns tempDir.resolve("Build/Products") every { EnvUtils.getCLIVersion() } returns CliVersion.parse("1.2.0") every { EnvUtils.CLI_VERSION } returns CliVersion.parse("1.2.0") val driverDirectory = Files.createDirectories(Paths.get(tempDir.pathString + "/maestro-iphoneos-driver-build")) val propertiesFile = driverDirectory.resolve("version.properties") val teamId = "dummy-team" val destination = "destination" Files.newBufferedWriter(propertiesFile).use { writer -> val props = Properties() props.setProperty("version", "1.1.0") // Outdated version props.store(writer, null) } // Call RealIOSDeviceDriver's validateAndUpdateDriver RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder) .validateAndUpdateDriver(driverRootDirectory = tempDir) // Verify that the driver was built due to outdated version verify(exactly = 1) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called driverDirectory.toFile().deleteRecursively() } @Test fun `should not update driver when version is up to date`() { // Set up a test version.properties file with an outdated version // Mock CLI_VERSION in EnvUtils to return "1.2.0" mockkObject(EnvUtils) val driverBuilder = mockk() every { driverBuilder.buildDriver(any()) } returns tempDir.resolve("Build/Products") every { EnvUtils.getCLIVersion() } returns CliVersion.parse("1.3.0") every { EnvUtils.CLI_VERSION } returns CliVersion.parse("1.3.0") val driverDirectory = Files.createDirectories(Paths.get(tempDir.pathString + "/maestro-iphoneos-driver-build")) val productDirectory = driverDirectory.resolve("driver-iphoneos").resolve("Build").resolve("Products").createDirectories() productDirectory.resolve("maestro-driver-ios-config.xctestrun").createFile() .apply { writeText("Fake Runner xctestrun file") } val propertiesFile = driverDirectory.resolve("version.properties") val teamId = "dummy-team" val destination = "destination" Files.newBufferedWriter(propertiesFile).use { writer -> val props = Properties() props.setProperty("version", "1.3.0") // Outdated version props.store(writer, null) } // Call RealIOSDeviceDriver's validateAndUpdateDriver RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder) .validateAndUpdateDriver(driverRootDirectory = tempDir) // Verify that the driver was built due to outdated version verify(exactly = 0) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called driverDirectory.toFile().deleteRecursively() } @Test fun `should update driver when version file is missing`() { // Set up a test version.properties file with an outdated version // Mock CLI_VERSION in EnvUtils to return "1.2.0" mockkObject(EnvUtils) every { EnvUtils.getCLIVersion() } returns CliVersion.parse("1.3.0") every { EnvUtils.CLI_VERSION } returns CliVersion.parse("1.3.0") val driverBuilder = mockk() every { driverBuilder.buildDriver(any()) } returns tempDir.resolve("Build/Products") val teamId = "dummy-team" val destination = "destination" // Call RealIOSDeviceDriver's validateAndUpdateDriver RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder) .validateAndUpdateDriver(driverRootDirectory = tempDir) // Verify that the driver was built due to outdated version verify(exactly = 1) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called } @AfterEach fun cleanup() { clearAllMocks() } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/report/HtmlTestSuiteReporterTest.kt ================================================ package maestro.cli.report import com.google.common.truth.Truth.assertThat import okio.Buffer import org.junit.jupiter.api.Test class HtmlTestSuiteReporterTest : TestSuiteReporterTest() { @Test fun `HTML - Test passed`() { // Given val testee = HtmlTestSuiteReporter() val sink = Buffer() // When testee.report( summary = testSuccessWithWarning, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ Maestro Test Report

Flow Execution Summary


Test Result: PASSED
Duration: 31m 55.947s
Start Time: $nowAsIso

Total number of Flows

2

Failed Flows

0

Successful Flows

2

Status: SUCCESS
Duration: 7m 1.573s
Start Time: $nowPlus1AsIso
File Name: flow_a

Status: WARNING
Duration: 24m 54.749s
Start Time: $nowPlus2AsIso
File Name: flow_b

""".trimIndent() ) } @Test fun `HTML - Test failed`() { // Given val testee = HtmlTestSuiteReporter() val sink = Buffer() // When testee.report( summary = testSuccessWithError, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ Maestro Test Report

Flow Execution Summary


Test Result: FAILED
Duration: 9m 12.743s
Start Time: $nowAsIso

Total number of Flows

2

Failed Flows

1

Successful Flows

1

Failed Flow

Flow B

Status: SUCCESS
Duration: 7m 1.573s
Start Time: $nowPlus1AsIso
File Name: flow_a

Status: ERROR
Duration: 2m 11.846s
Start Time: $nowPlus2AsIso
File Name: flow_b

Error message

""".trimIndent() ) } @Test fun `HTML - Pretty mode with successful test and steps`() { // Given val testee = HtmlTestSuiteReporter(detailed = true) val sink = Buffer() // When testee.report( summary = testSuccessWithSteps, out = sink ) val resultStr = sink.readUtf8() // Then // Verify key elements are present assertThat(resultStr).contains("Test Steps (3)") assertThat(resultStr).contains("✅") assertThat(resultStr).contains("1. Launch app") assertThat(resultStr).contains("2. Tap on button") assertThat(resultStr).contains("3. Assert visible") assertThat(resultStr).contains("1.2s") assertThat(resultStr).contains("500ms") assertThat(resultStr).contains("100ms") assertThat(resultStr).contains(".step-item") assertThat(resultStr).contains(".step-header") assertThat(resultStr).contains(".step-name") // Verify proper HTML structure assertThat(resultStr).contains("") assertThat(resultStr).contains("") assertThat(resultStr).contains("Flow Execution Summary") assertThat(resultStr).contains("Test Result: PASSED") } @Test fun `HTML - Pretty mode with failed test and steps with various statuses`() { // Given val testee = HtmlTestSuiteReporter(detailed = true) val sink = Buffer() // When testee.report( summary = testErrorWithSteps, out = sink ) val resultStr = sink.readUtf8() // Then // Verify key elements and various step statuses assertThat(resultStr).contains("Test Steps (4)") assertThat(resultStr).contains("✅") // COMPLETED assertThat(resultStr).contains("⚠️") // WARNED assertThat(resultStr).contains("❌") // FAILED assertThat(resultStr).contains("⏭️") // SKIPPED assertThat(resultStr).contains("1. Launch app") assertThat(resultStr).contains("2. Tap on optional element") assertThat(resultStr).contains("3. Tap on button") assertThat(resultStr).contains("4. Assert visible") assertThat(resultStr).contains("Element not found") assertThat(resultStr).contains(".step-item") assertThat(resultStr).contains(".step-header") assertThat(resultStr).contains(".step-name") // Verify proper HTML structure assertThat(resultStr).contains("") assertThat(resultStr).contains("") assertThat(resultStr).contains("Flow Execution Summary") assertThat(resultStr).contains("Test Result: FAILED") assertThat(resultStr).contains("Failed Flow") } @Test fun `HTML - Basic mode does not show steps even when present`() { // Given val testee = HtmlTestSuiteReporter(detailed = false) val sink = Buffer() // When testee.report( summary = testSuccessWithSteps, out = sink ) val resultStr = sink.readUtf8() // Then // Should not contain step details assertThat(resultStr).doesNotContain("Test Steps") assertThat(resultStr).doesNotContain("Launch app") assertThat(resultStr).doesNotContain("step-item") assertThat(resultStr).doesNotContain("step-header") // Should contain basic flow information assertThat(resultStr).contains("Flow A") assertThat(resultStr).contains("Status: SUCCESS") assertThat(resultStr).contains("File Name: flow_a") } @Test fun `HTML - Tags and properties are displayed`() { // Given val testee = HtmlTestSuiteReporter(detailed = false) val sink = Buffer() // When testee.report( summary = testWithTagsAndProperties, out = sink ) val resultStr = sink.readUtf8() // Then // Verify tags are displayed assertThat(resultStr).contains("Tags:") assertThat(resultStr).contains("smoke") assertThat(resultStr).contains("critical") assertThat(resultStr).contains("auth") assertThat(resultStr).contains("regression") assertThat(resultStr).contains("e2e") assertThat(resultStr).contains("badge bg-primary") // Verify properties section and table assertThat(resultStr).contains("Properties") assertThat(resultStr).contains("testCaseId") assertThat(resultStr).contains("TC-001") assertThat(resultStr).contains("xray-test-key") assertThat(resultStr).contains("PROJ-123") assertThat(resultStr).contains("priority") assertThat(resultStr).contains("P0") assertThat(resultStr).contains("TC-002") assertThat(resultStr).contains("testrail-case-id") assertThat(resultStr).contains("C456") assertThat(resultStr).contains("table table-sm table-bordered") // Verify flow names assertThat(resultStr).contains("Login Flow") assertThat(resultStr).contains("Checkout Flow") } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/report/JUnitTestSuiteReporterTest.kt ================================================ package maestro.cli.report import com.google.common.truth.Truth.assertThat import okio.Buffer import org.junit.jupiter.api.Test class JUnitTestSuiteReporterTest : TestSuiteReporterTest() { @Test fun `XML - Test passed`() { // Given val testee = JUnitTestSuiteReporter.xml() val sink = Buffer() // When testee.report( summary = testSuccessWithWarning, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ """.trimIndent() ) } @Test fun `XML - Test failed`() { // Given val testee = JUnitTestSuiteReporter.xml() val sink = Buffer() // When testee.report( summary = testSuccessWithError, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ Error message """.trimIndent() ) } @Test fun `XML - Custom test suite name is used when present`() { // Given val testee = JUnitTestSuiteReporter.xml("Custom test suite name") val sink = Buffer() // When testee.report( summary = testSuccessWithWarning, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ """.trimIndent() ) } @Test fun `XML - Tags and properties are included in output`() { // Given val testee = JUnitTestSuiteReporter.xml() val sink = Buffer() // When testee.report( summary = testWithTagsAndProperties, out = sink ) val resultStr = sink.readUtf8() // Then assertThat(resultStr).isEqualTo( """ """.trimIndent() ) } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/report/TestDebugReporterTest.kt ================================================ package maestro.cli.report import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import kotlin.io.path.createDirectories import kotlin.io.path.exists import kotlin.io.path.pathString class TestDebugReporterTest { @TempDir lateinit var tempDir: Path @Test fun `will delete old files`() { // Create directory structure, and an old test directory val oldDir = Files.createDirectories(tempDir.resolve(".maestro/tests/old")) Files.setLastModifiedTime(oldDir, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 15)) // Initialise a new test reporter, which will create ./maestro/tests/ TestDebugReporter.install(tempDir.pathString, false,false) // Run the deleteOldFiles method, which happens at the end of each test run // This should delete the 'old' directory created above TestDebugReporter.deleteOldFiles() assertThat(Files.exists(oldDir)).isFalse() // Verify that the old directory was deleted assertThat(TestDebugReporter.getDebugOutputPath().exists()).isTrue() // Verify that the logs from this run still exist } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/report/TestSuiteReporterTest.kt ================================================ package maestro.cli.report import maestro.cli.model.FlowStatus import maestro.cli.model.TestExecutionSummary import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import kotlin.time.Duration.Companion.milliseconds abstract class TestSuiteReporterTest { // Since timestamps we get from the server have milliseconds precision (specifically epoch millis) // we need to truncate off nanoseconds (and any higher) precision. val now = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS) val nowPlus1 = now.plusSeconds(1) val nowPlus2 = now.plusSeconds(2) val nowAsIso = now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) val nowPlus1AsIso = nowPlus1.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) val nowPlus2AsIso = nowPlus2.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) val testSuccessWithWarning = TestExecutionSummary( passed = true, suites = listOf( TestExecutionSummary.SuiteResult( passed = true, deviceName = "iPhone 15", flows = listOf( TestExecutionSummary.FlowResult( name = "Flow A", fileName = "flow_a", status = FlowStatus.SUCCESS, duration = 421573.milliseconds, startTime = nowPlus1.toInstant().toEpochMilli() ), TestExecutionSummary.FlowResult( name = "Flow B", fileName = "flow_b", status = FlowStatus.WARNING, duration = 1494749.milliseconds, startTime = nowPlus2.toInstant().toEpochMilli() ), ), duration = 1915947.milliseconds, startTime = now.toInstant().toEpochMilli() ) ) ) val testSuccessWithError = TestExecutionSummary( passed = false, suites = listOf( TestExecutionSummary.SuiteResult( passed = false, flows = listOf( TestExecutionSummary.FlowResult( name = "Flow A", fileName = "flow_a", status = FlowStatus.SUCCESS, duration = 421573.milliseconds, startTime = nowPlus1.toInstant().toEpochMilli() ), TestExecutionSummary.FlowResult( name = "Flow B", fileName = "flow_b", status = FlowStatus.ERROR, failure = TestExecutionSummary.Failure("Error message"), duration = 131846.milliseconds, startTime = nowPlus2.toInstant().toEpochMilli() ), ), duration = 552743.milliseconds, startTime = now.toInstant().toEpochMilli() ) ) ) val testSuccessWithSteps = TestExecutionSummary( passed = true, suites = listOf( TestExecutionSummary.SuiteResult( passed = true, flows = listOf( TestExecutionSummary.FlowResult( name = "Flow A", fileName = "flow_a", status = FlowStatus.SUCCESS, duration = 5000.milliseconds, startTime = nowPlus1.toInstant().toEpochMilli(), steps = listOf( TestExecutionSummary.StepResult( description = "1. Launch app", status = "COMPLETED", duration = "1.2s" ), TestExecutionSummary.StepResult( description = "2. Tap on button", status = "COMPLETED", duration = "500ms" ), TestExecutionSummary.StepResult( description = "3. Assert visible", status = "COMPLETED", duration = "100ms" ), ) ), ), duration = 5000.milliseconds, startTime = now.toInstant().toEpochMilli() ) ) ) val testErrorWithSteps = TestExecutionSummary( passed = false, suites = listOf( TestExecutionSummary.SuiteResult( passed = false, flows = listOf( TestExecutionSummary.FlowResult( name = "Flow B", fileName = "flow_b", status = FlowStatus.ERROR, failure = TestExecutionSummary.Failure("Element not found"), duration = 3000.milliseconds, startTime = nowPlus1.toInstant().toEpochMilli(), steps = listOf( TestExecutionSummary.StepResult( description = "1. Launch app", status = "COMPLETED", duration = "1.5s" ), TestExecutionSummary.StepResult( description = "2. Tap on optional element", status = "WARNED", duration = "<1ms" ), TestExecutionSummary.StepResult( description = "3. Tap on button", status = "FAILED", duration = "2.0s" ), TestExecutionSummary.StepResult( description = "4. Assert visible", status = "SKIPPED", duration = "0ms" ), ) ), ), duration = 3000.milliseconds, startTime = now.toInstant().toEpochMilli() ) ) ) val testWithTagsAndProperties = TestExecutionSummary( passed = true, suites = listOf( TestExecutionSummary.SuiteResult( passed = true, flows = listOf( TestExecutionSummary.FlowResult( name = "Login Flow", fileName = "login_flow", status = FlowStatus.SUCCESS, duration = 2500.milliseconds, startTime = nowPlus1.toInstant().toEpochMilli(), tags = listOf("smoke", "critical", "auth"), properties = mapOf( "testCaseId" to "TC-001", "xray-test-key" to "PROJ-123", "priority" to "P0" ) ), TestExecutionSummary.FlowResult( name = "Checkout Flow", fileName = "checkout_flow", status = FlowStatus.SUCCESS, duration = 3500.milliseconds, startTime = nowPlus2.toInstant().toEpochMilli(), tags = listOf("regression", "e2e"), properties = mapOf( "testCaseId" to "TC-002", "testrail-case-id" to "C456" ) ), ), duration = 6000.milliseconds, startTime = now.toInstant().toEpochMilli() ) ) ) } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/runner/resultview/PlainTextResultViewTest.kt ================================================ package maestro.cli.runner.resultview import com.google.common.truth.Truth.assertThat import maestro.cli.runner.CommandState import maestro.cli.runner.CommandStatus import maestro.orchestra.AssertConditionCommand import maestro.orchestra.Condition import maestro.orchestra.ElementSelector import maestro.orchestra.MaestroCommand import maestro.orchestra.RunFlowCommand import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.ByteArrayOutputStream import java.io.PrintStream class PlainTextResultViewTest { private lateinit var outputStream: ByteArrayOutputStream private lateinit var originalOut: PrintStream @BeforeEach fun setUp() { outputStream = ByteArrayOutputStream() originalOut = System.out System.setOut(PrintStream(outputStream)) } private fun tearDown() { System.setOut(originalOut) } private fun getOutput(): String { return outputStream.toString() } /** * This test verifies that deeply nested runFlow commands are all printed correctly. * * Bug description: When using nested complex runFlow inside runFlow (and even deeper), * the --no-ansi option fails to keep track and stops printing. It gets out of sync. * * Example structure that was failing: * main.yml: * - runFlow: open_app.yml <- printed fine * - runFlow: login_to_app.yml <- printed fine * - runFlow: tests.yml <- this flow and everything in it were NOT being printed * * Where tests.yml contains: * - runFlow: test1.yml * - runFlow: test2.yml * * And login_to_app.yml contains a conditional runFlow. * * The fix ensures unique keys are generated for each nested command by using * hierarchical prefixes (e.g., "main:0:sub:0:sub:0") instead of flat indices. */ @Test fun `nested runFlow commands should all be printed correctly`() { // Given val resultView = PlainTextResultView() // Create a deeply nested structure similar to the bug scenario: // main flow -> runFlow (tests.yml) -> runFlow (test1.yml) -> assertVisible val deepestCommand = MaestroCommand( assertConditionCommand = AssertConditionCommand( condition = Condition(visible = ElementSelector(textRegex = "hello")) ) ) val deepestCommandState = CommandState( status = CommandStatus.COMPLETED, command = deepestCommand, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = null ) // test1.yml - contains assertVisible val test1RunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(deepestCommand), sourceDescription = "test1.yml", config = null ) ) val test1State = CommandState( status = CommandStatus.COMPLETED, command = test1RunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(deepestCommandState) ) // test2.yml - another nested flow val test2RunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(deepestCommand), sourceDescription = "test2.yml", config = null ) ) val test2State = CommandState( status = CommandStatus.COMPLETED, command = test2RunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(deepestCommandState.copy()) ) // tests.yml - contains test1 and test2 val testsRunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(test1RunFlow, test2RunFlow), sourceDescription = "tests.yml", config = null ) ) val testsState = CommandState( status = CommandStatus.COMPLETED, command = testsRunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(test1State, test2State) ) // open_app.yml - simple flow val openAppRunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(deepestCommand), sourceDescription = "open_app.yml", config = null ) ) val openAppState = CommandState( status = CommandStatus.COMPLETED, command = openAppRunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(deepestCommandState.copy()) ) // login_to_app.yml - contains a conditional runFlow val conditionalRunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(deepestCommand), condition = Condition(visible = ElementSelector(textRegex = "name@example.com")), sourceDescription = null, config = null ) ) val conditionalState = CommandState( status = CommandStatus.COMPLETED, command = conditionalRunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(deepestCommandState.copy()) ) val loginRunFlow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(conditionalRunFlow), sourceDescription = "login_to_app.yml", config = null ) ) val loginState = CommandState( status = CommandStatus.COMPLETED, command = loginRunFlow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(conditionalState) ) // Main flow state with all commands val state = UiState.Running( flowName = "main.yml", commands = listOf( openAppState, loginState, testsState // This was the problematic flow that wasn't being printed ) ) // When resultView.setState(state) // Then val output = getOutput() tearDown() // Verify all nested flows are printed assertThat(output).contains("Run open_app.yml") assertThat(output).contains("Run login_to_app.yml") assertThat(output).contains("Run tests.yml") // This was missing before the fix assertThat(output).contains("Run test1.yml") // Nested inside tests.yml assertThat(output).contains("Run test2.yml") // Nested inside tests.yml // Verify the deepest commands are also printed (assertVisible) // Count occurrences - we should have multiple "Assert that" for each nested flow val assertCount = output.split("Assert that").size - 1 assertThat(assertCount).isAtLeast(3) // At least 3 assertVisible commands should be printed } @Test fun `multiple calls with same nested structure should not duplicate output`() { // Given val resultView = PlainTextResultView() val command = MaestroCommand( assertConditionCommand = AssertConditionCommand( condition = Condition(visible = ElementSelector(textRegex = "hello")) ) ) val commandState = CommandState( status = CommandStatus.COMPLETED, command = command, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = null ) val runFlowCommand = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(command), sourceDescription = "test.yml", config = null ) ) val runFlowState = CommandState( status = CommandStatus.COMPLETED, command = runFlowCommand, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(commandState) ) val state = UiState.Running( flowName = "main.yml", commands = listOf(runFlowState) ) // When - call setState multiple times (simulating UI updates) resultView.setState(state) resultView.setState(state) resultView.setState(state) // Then val output = getOutput() tearDown() // Should only print once despite multiple setState calls val flowNameCount = output.split("Flow main.yml").size - 1 assertThat(flowNameCount).isEqualTo(1) val runTestCount = output.split("Run test.yml").size - 1 assertThat(runTestCount).isEqualTo(2) // Once for start, once for complete } @Test fun `three levels of nested runFlow should all print`() { // Given val resultView = PlainTextResultView() // Level 3: deepest assert val assertCommand = MaestroCommand( assertConditionCommand = AssertConditionCommand( condition = Condition(visible = ElementSelector(textRegex = "deep")) ) ) val assertState = CommandState( status = CommandStatus.COMPLETED, command = assertCommand, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = null ) // Level 2: middle runFlow (level2.yml) val level2Flow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(assertCommand), sourceDescription = "level2.yml", config = null ) ) val level2State = CommandState( status = CommandStatus.COMPLETED, command = level2Flow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(assertState) ) // Level 1: outer runFlow (level1.yml) val level1Flow = MaestroCommand( runFlowCommand = RunFlowCommand( commands = listOf(level2Flow), sourceDescription = "level1.yml", config = null ) ) val level1State = CommandState( status = CommandStatus.COMPLETED, command = level1Flow, subOnStartCommands = null, subOnCompleteCommands = null, subCommands = listOf(level2State) ) val state = UiState.Running( flowName = "main.yml", commands = listOf(level1State) ) // When resultView.setState(state) // Then val output = getOutput() tearDown() // All levels should be printed assertThat(output).contains("Run level1.yml") assertThat(output).contains("Run level2.yml") assertThat(output).contains("Assert that") } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/util/ChangeLogUtilsTest.kt ================================================ package maestro.cli.util import com.google.common.truth.Truth.assertThat import maestro.cli.util.EnvUtils.CLI_VERSION import org.junit.jupiter.api.Test import java.io.File class ChangeLogUtilsTest { private val changelogFile = File(System.getProperty("user.dir"), "../CHANGELOG.md") @Test fun `test format last version`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, CLI_VERSION.toString()) assertThat(changelog).isNotNull() assertThat(changelog).isNotEmpty() } @Test fun `test format unknown version`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "x.yy.z") assertThat(changelog).isNull() } @Test fun `test format short version`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "1.38.1") assertThat(changelog).containsExactly( "- New experimental AI-powered commands for screenshot testing: assertWithAI and assertNoDefectsWithAI (#1906)", "- Enable basic support for Maestro uploads while keeping Maestro Cloud functioning (#1970)", ) } @Test fun `test format link and no paragraph`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "1.37.9") assertThat(changelog).containsExactly( "- Revert iOS landscape mode fix (#1916)", ) } @Test fun `test format no subheader`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "1.37.1") assertThat(changelog).containsExactly( "- Fix crash when `flutter` or `xcodebuild` is not installed (#1839)", ) } @Test fun `test format strong no paragraph and no sublist`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "1.37.0") assertThat(changelog).containsExactly( "- Sharding tests for parallel execution on many devices 🎉 (#1732 by Kaan)", "- Reports in HTML (#1750 by Depa Panjie Purnama)", "- Homebrew is back!", "- Current platform exposed in JavaScript (#1747 by Dan Caseley)", "- Control airplane mode (#1672 by NyCodeGHG)", "- New `killApp` command (#1727 by Alexandre Favre)", "- Fix cleaning up retries in iOS driver (#1669)", "- Fix some commands not respecting custom labels (#1762 by Dan Caseley)", "- Fix “Protocol family unavailable” when rerunning iOS tests (#1671 by Stanisław Chmiela)", ) } @Test fun `test print`() { val content = changelogFile.readText() val changelog = ChangeLogUtils.formatBody(content, "1.17.1") val printed = ChangeLogUtils.print(changelog) assertThat(printed).isEqualTo( "\n" + "- Tweak: Remove Maestro Studio icon from Mac dock\n" + "- Tweak: Prefer port 9999 for Maestro Studio app\n" + "- Fix: Fix Maestro Studio conditional code snippet\n" ) } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/util/DependencyResolverTest.kt ================================================ package maestro.cli.util import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import kotlin.io.path.writeText class DependencyResolverTest { @Test fun `test dependency discovery for single flow file`(@TempDir tempDir: Path) { // Create a main flow file val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: subflow1.yaml - runFlow: subflow2.yaml - runScript: validation.js - addMedia: - "images/logo.png" """.trimIndent()) // Create subflow files val subflow1 = tempDir.resolve("subflow1.yaml") subflow1.writeText(""" appId: com.example.app --- - tapOn: "Button" """.trimIndent()) val subflow2 = tempDir.resolve("subflow2.yaml") subflow2.writeText(""" appId: com.example.app --- - runFlow: nested_subflow.yaml """.trimIndent()) // Create nested subflow val nestedSubflow = tempDir.resolve("nested_subflow.yaml") nestedSubflow.writeText(""" appId: com.example.app --- - assertVisible: "Text" """.trimIndent()) // Create script file val script = tempDir.resolve("validation.js") script.writeText("console.log('validation script');") // Create media file val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val mediaFile = mediaDir.resolve("logo.png") mediaFile.writeText("fake png content") // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should include all files assertThat(dependencies).hasSize(6) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(subflow1) assertThat(dependencies).contains(subflow2) assertThat(dependencies).contains(nestedSubflow) assertThat(dependencies).contains(script) assertThat(dependencies).contains(mediaFile) } @Test fun `test dependency summary generation`(@TempDir tempDir: Path) { val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: subflow.yaml - runScript: script.js - addMedia: - "images/logo.png" """.trimIndent()) val subflow = tempDir.resolve("subflow.yaml") subflow.writeText(""" appId: com.example.app --- - tapOn: "Button" """.trimIndent()) val script = tempDir.resolve("script.js") script.writeText("console.log('test');") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val mediaFile = mediaDir.resolve("logo.png") mediaFile.writeText("fake png content") val summary = DependencyResolver.getDependencySummary(mainFlow) assertThat(summary).contains("Total files: 4") assertThat(summary).contains("Subflows: 1") assertThat(summary).contains("Scripts: 1") assertThat(summary).contains("Other files: 1") } @Test fun `test enhanced dependency discovery finds all types`(@TempDir tempDir: Path) { // Create a main flow file with runScript and addMedia val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: subflow.yaml - runScript: script.js - addMedia: - "images/logo.png" """.trimIndent()) val subflow = tempDir.resolve("subflow.yaml") subflow.writeText(""" appId: com.example.app --- - tapOn: "Button" """.trimIndent()) val script = tempDir.resolve("script.js") script.writeText("console.log('test');") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val mediaFile = mediaDir.resolve("logo.png") mediaFile.writeText("fake png content") // Test enhanced discovery (should find all dependencies) val enhancedDependencies = DependencyResolver.discoverAllDependencies(mainFlow) assertThat(enhancedDependencies).hasSize(4) assertThat(enhancedDependencies).contains(script) assertThat(enhancedDependencies).contains(mediaFile) assertThat(enhancedDependencies).contains(subflow) } @Test fun `test composite commands - repeat with nested dependencies`(@TempDir tempDir: Path) { // Create main flow with repeat command containing nested dependencies val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - repeat: times: 3 commands: - runFlow: nested_subflow.yaml - runScript: validation.js - addMedia: - "images/repeat_logo.png" """.trimIndent()) // Create nested dependencies val nestedSubflow = tempDir.resolve("nested_subflow.yaml") nestedSubflow.writeText(""" appId: com.example.app --- - tapOn: "Button" """.trimIndent()) val script = tempDir.resolve("validation.js") script.writeText("console.log('repeat validation');") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val mediaFile = mediaDir.resolve("repeat_logo.png") mediaFile.writeText("fake png content") // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find all nested dependencies within repeat command assertThat(dependencies).hasSize(4) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(nestedSubflow) assertThat(dependencies).contains(script) assertThat(dependencies).contains(mediaFile) } @Test fun `test composite commands - retry with file reference`(@TempDir tempDir: Path) { // Create main flow with retry command referencing external file val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - retry: file: external_retry.yaml maxRetries: 3 """.trimIndent()) // Create external retry file val retryFile = tempDir.resolve("external_retry.yaml") retryFile.writeText(""" appId: com.example.app --- - tapOn: "Retry Button" - runFlow: nested_flow.yaml """.trimIndent()) // Create nested flow referenced by retry file val nestedFlow = tempDir.resolve("nested_flow.yaml") nestedFlow.writeText(""" appId: com.example.app --- - assertVisible: "Success" """.trimIndent()) // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find retry file and its nested dependencies assertThat(dependencies).hasSize(3) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(retryFile) assertThat(dependencies).contains(nestedFlow) } @Test fun `test composite commands - retry with inline commands`(@TempDir tempDir: Path) { // Create main flow with retry command containing inline nested dependencies val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - retry: maxRetries: 2 commands: - runFlow: retry_subflow.yaml - runScript: cleanup.js - addMedia: - "images/retry_media.png" """.trimIndent()) // Create nested dependencies val retrySubflow = tempDir.resolve("retry_subflow.yaml") retrySubflow.writeText(""" appId: com.example.app --- - tapOn: "Retry Action" """.trimIndent()) val cleanupScript = tempDir.resolve("cleanup.js") cleanupScript.writeText("console.log('cleanup after retry');") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val retryMedia = mediaDir.resolve("retry_media.png") retryMedia.writeText("fake retry media content") // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find all nested dependencies within retry command assertThat(dependencies).hasSize(4) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(retrySubflow) assertThat(dependencies).contains(cleanupScript) assertThat(dependencies).contains(retryMedia) } @Test fun `test deeply nested composite commands`(@TempDir tempDir: Path) { // Create complex nested structure: runFlow -> repeat -> retry -> runFlow val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: commands: - repeat: times: 2 commands: - retry: maxRetries: 3 commands: - runFlow: deeply_nested.yaml - runScript: deep_script.js """.trimIndent()) // Create deeply nested dependencies val deeplyNested = tempDir.resolve("deeply_nested.yaml") deeplyNested.writeText(""" appId: com.example.app --- - tapOn: "Deep Button" - addMedia: - "images/deep_media.png" """.trimIndent()) val deepScript = tempDir.resolve("deep_script.js") deepScript.writeText("console.log('deeply nested script');") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val deepMedia = mediaDir.resolve("deep_media.png") deepMedia.writeText("deep media content") // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find all dependencies at any nesting level assertThat(dependencies).hasSize(4) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(deeplyNested) assertThat(dependencies).contains(deepScript) assertThat(dependencies).contains(deepMedia) } @Test fun `test mixed composite commands with external and inline`(@TempDir tempDir: Path) { // Create flow mixing external file references and inline commands val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: external_flow.yaml - repeat: times: 2 commands: - runScript: inline_script.js - retry: file: external_retry.yaml """.trimIndent()) // Create external flow val externalFlow = tempDir.resolve("external_flow.yaml") externalFlow.writeText(""" appId: com.example.app --- - tapOn: "External Button" """.trimIndent()) // Create inline script val inlineScript = tempDir.resolve("inline_script.js") inlineScript.writeText("console.log('inline script in repeat');") // Create external retry val externalRetry = tempDir.resolve("external_retry.yaml") externalRetry.writeText(""" appId: com.example.app --- - assertVisible: "Retry Success" - runFlow: retry_nested.yaml """.trimIndent()) // Create retry nested flow val retryNested = tempDir.resolve("retry_nested.yaml") retryNested.writeText(""" appId: com.example.app --- - tapOn: "Final Button" """.trimIndent()) // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find all dependencies from mixed sources assertThat(dependencies).hasSize(5) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(externalFlow) assertThat(dependencies).contains(inlineScript) assertThat(dependencies).contains(externalRetry) assertThat(dependencies).contains(retryNested) } @Test fun `test circular dependency prevention`(@TempDir tempDir: Path) { // Create circular dependency: flow1 -> flow2 -> flow1 val flow1 = tempDir.resolve("flow1.yaml") flow1.writeText(""" appId: com.example.app --- - runFlow: flow2.yaml - tapOn: "Button1" """.trimIndent()) val flow2 = tempDir.resolve("flow2.yaml") flow2.writeText(""" appId: com.example.app --- - runFlow: flow1.yaml - tapOn: "Button2" """.trimIndent()) // Test dependency discovery should handle circular references val dependencies = DependencyResolver.discoverAllDependencies(flow1) // Should include both files but not loop infinitely // Note: The exact count may vary based on parsing, but should include at least flow1 // and should not hang due to circular reference assertThat(dependencies.size).isAtLeast(1) assertThat(dependencies).contains(flow1) // flow2 might not be included if parsing fails, but the important thing is no infinite loop } @Test fun `test dependency summary with composite commands`(@TempDir tempDir: Path) { val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - repeat: times: 2 commands: - runFlow: repeat_subflow.yaml - runScript: repeat_script.js - retry: file: retry_flow.yaml - addMedia: - "images/main_logo.png" """.trimIndent()) // Create all dependencies val repeatSubflow = tempDir.resolve("repeat_subflow.yaml") repeatSubflow.writeText("appId: com.example.app\n---\n- tapOn: 'Button'") val repeatScript = tempDir.resolve("repeat_script.js") repeatScript.writeText("console.log('repeat');") val retryFlow = tempDir.resolve("retry_flow.yaml") retryFlow.writeText("appId: com.example.app\n---\n- assertVisible: 'Text'") val mediaDir = tempDir.resolve("images") mediaDir.toFile().mkdirs() val mainLogo = mediaDir.resolve("main_logo.png") mainLogo.writeText("logo content") val summary = DependencyResolver.getDependencySummary(mainFlow) assertThat(summary).contains("Total files: 5") assertThat(summary).contains("Subflows: 2") // repeat_subflow.yaml + retry_flow.yaml assertThat(summary).contains("Scripts: 1") // repeat_script.js assertThat(summary).contains("Other files: 1") // main_logo.png } @Test fun `test configuration commands - onFlowStart and onFlowComplete with dependencies`(@TempDir tempDir: Path) { // Create main flow with onFlowStart and onFlowComplete containing file dependencies val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app onFlowStart: - runFlow: startup_flow.yaml - runScript: startup_script.js - addMedia: - "images/startup_logo.png" onFlowComplete: - runFlow: cleanup_flow.yaml - runScript: cleanup_script.js - addMedia: - "images/completion_badge.png" --- - tapOn: "Main Button" """.trimIndent()) // Create startup dependencies val startupFlow = tempDir.resolve("startup_flow.yaml") startupFlow.writeText(""" appId: com.example.app --- - tapOn: "Startup Button" - runFlow: nested_startup.yaml """.trimIndent()) val nestedStartup = tempDir.resolve("nested_startup.yaml") nestedStartup.writeText(""" appId: com.example.app --- - assertVisible: "Startup Complete" """.trimIndent()) val startupScript = tempDir.resolve("startup_script.js") startupScript.writeText("console.log('startup initialization');") val imagesDir = tempDir.resolve("images") imagesDir.toFile().mkdirs() val startupLogo = imagesDir.resolve("startup_logo.png") startupLogo.writeText("startup logo content") // Create cleanup dependencies val cleanupFlow = tempDir.resolve("cleanup_flow.yaml") cleanupFlow.writeText(""" appId: com.example.app --- - tapOn: "Cleanup Button" """.trimIndent()) val cleanupScript = tempDir.resolve("cleanup_script.js") cleanupScript.writeText("console.log('cleanup finalization');") val completionBadge = imagesDir.resolve("completion_badge.png") completionBadge.writeText("completion badge content") // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find all dependencies from onFlowStart and onFlowComplete assertThat(dependencies).hasSize(8) // main + 2 startup flows + 2 scripts + 2 images + 1 cleanup flow assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(startupFlow) assertThat(dependencies).contains(nestedStartup) assertThat(dependencies).contains(startupScript) assertThat(dependencies).contains(startupLogo) assertThat(dependencies).contains(cleanupFlow) assertThat(dependencies).contains(cleanupScript) assertThat(dependencies).contains(completionBadge) } @Test fun `test mixed configuration and composite commands`(@TempDir tempDir: Path) { // Create complex flow with both configuration commands and composite commands val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app onFlowStart: - repeat: times: 2 commands: - runFlow: repeated_startup.yaml onFlowComplete: - retry: maxRetries: 3 commands: - runScript: retry_cleanup.js --- - tapOn: "Main Action" - runFlow: main_subflow.yaml """.trimIndent()) // Create all dependencies val repeatedStartup = tempDir.resolve("repeated_startup.yaml") repeatedStartup.writeText(""" appId: com.example.app --- - tapOn: "Repeated Action" """.trimIndent()) val retryCleanup = tempDir.resolve("retry_cleanup.js") retryCleanup.writeText("console.log('retry cleanup');") val mainSubflow = tempDir.resolve("main_subflow.yaml") mainSubflow.writeText(""" appId: com.example.app --- - assertVisible: "Main Complete" """.trimIndent()) // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should find dependencies from configuration commands AND main flow assertThat(dependencies).hasSize(4) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(repeatedStartup) // From onFlowStart -> repeat -> runFlow assertThat(dependencies).contains(retryCleanup) // From onFlowComplete -> retry -> runScript assertThat(dependencies).contains(mainSubflow) // From main flow -> runFlow } @Test fun `test dependency discovery for repeated flow references`(@TempDir tempDir: Path) { // Create a main flow file val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - runFlow: subflow.yaml - runFlow: subflow.yaml - runFlow: commands: - runFlow: subflow.yaml """.trimIndent()) // Create subflow files val subflow1 = tempDir.resolve("subflow.yaml") subflow1.writeText(""" appId: com.example.app --- - tapOn: "Button" """.trimIndent()) // Test dependency discovery val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Should include all files assertThat(dependencies).hasSize(2) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(subflow1) } @Test fun `deduplicates same script referenced via different relative paths`(@TempDir tempDir: Path) { // Directory layout: // tempDir/ // main_flow.yaml // sub/ // subflow.yaml // scripts/ // createAccount.js // Create directories tempDir.resolve("sub").toFile().mkdirs() tempDir.resolve("scripts").toFile().mkdirs() // Create shared script val script = tempDir.resolve("scripts/createAccount.js") script.writeText("console.log('create account');") // Subflow that references script with a simple relative path val subflow = tempDir.resolve("sub/subflow.yaml") subflow.writeText( """ appId: com.example.app --- - runScript: ../scripts/createAccount.js """.trimIndent() ) // Main flow references the same script using a different path notation and the subflow val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText( """ appId: com.example.app --- - runFlow: sub/subflow.yaml - runScript: ./sub/../scripts/createAccount.js """.trimIndent() ) val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Expect exactly one instance of the script, despite two different references val scriptCount = dependencies.count { it.fileName.toString() == "createAccount.js" } assertThat(scriptCount).isEqualTo(1) // Also expect both flows to be included assertThat(dependencies.any { it.fileName.toString() == "main_flow.yaml" }).isTrue() assertThat(dependencies.any { it.fileName.toString() == "subflow.yaml" }).isTrue() } @Test fun `test assertScreenshot reference image is discovered as a dependency`(@TempDir tempDir: Path) { val referenceImage = tempDir.resolve("reference.png") referenceImage.writeText("fake png content") val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - assertScreenshot: reference.png """.trimIndent()) val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) assertThat(dependencies).hasSize(2) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(referenceImage) } @Test fun `test assertScreenshot inside repeat block is discovered as a dependency`(@TempDir tempDir: Path) { val referenceImage = tempDir.resolve("reference.png") referenceImage.writeText("fake png content") val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText(""" appId: com.example.app --- - repeat: times: 3 commands: - assertScreenshot: reference.png """.trimIndent()) val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) assertThat(dependencies).hasSize(2) assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(referenceImage) } @Test fun `treats files with same name but different real paths as different dependencies`(@TempDir tempDir: Path) { // Directory layout: // tempDir/ // main_flow.yaml // scripts/ // createUser.js // usa/ // createUser.js // Create directories tempDir.resolve("scripts").toFile().mkdirs() tempDir.resolve("scripts/usa").toFile().mkdirs() // Create two different scripts with the same filename val script1 = tempDir.resolve("scripts/createUser.js") script1.writeText("console.log('create user - default');") val script2 = tempDir.resolve("scripts/usa/createUser.js") script2.writeText("console.log('create user - USA');") // Main flow references both scripts val mainFlow = tempDir.resolve("main_flow.yaml") mainFlow.writeText( """ appId: com.example.app --- - runScript: scripts/createUser.js - runScript: scripts/usa/createUser.js """.trimIndent() ) val dependencies = DependencyResolver.discoverAllDependencies(mainFlow) // Expect both scripts to be included as separate dependencies assertThat(dependencies).hasSize(3) // main_flow.yaml + 2 scripts assertThat(dependencies).contains(mainFlow) assertThat(dependencies).contains(script1) assertThat(dependencies).contains(script2) // Verify both are present by checking their real paths val scriptPaths = dependencies.filter { it.fileName.toString() == "createUser.js" } assertThat(scriptPaths).hasSize(2) assertThat(scriptPaths).contains(script1) assertThat(scriptPaths).contains(script2) } } ================================================ FILE: maestro-cli/src/test/kotlin/maestro/cli/util/WorkspaceUtilsTest.kt ================================================ package maestro.cli.util import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.readText class WorkspaceUtilsTest { @Test fun `includes files outside workspace directory using path traversal`(@TempDir tempDir: Path) { // Layout: // tempDir/ // flows/main.yaml // scripts/outside.js val flowsDir = tempDir.resolve("flows").toFile() flowsDir.mkdirs() val scriptsDir = tempDir.resolve("scripts").toFile() scriptsDir.mkdirs() val outsideScript = tempDir.resolve("scripts/outside.js") Files.writeString(outsideScript, "console.log('outside');") val mainFlow = tempDir.resolve("flows/main.yaml") Files.writeString( mainFlow, """ appId: com.example.app --- - runScript: ../scripts/outside.js """.trimIndent() ) // Create ZIP val outZip = tempDir.resolve("workspace.zip") WorkspaceUtils.createWorkspaceZip(mainFlow, outZip) // Open ZIP FS and collect entry names val zipUri = URI.create("jar:${outZip.toUri()}") val entryNames = mutableListOf() FileSystems.newFileSystem(zipUri, emptyMap()).use { fs -> Files.walk(fs.getPath("/")).use { paths -> paths.filter { Files.isRegularFile(it) } .forEach { entryNames.add(it.toString().removePrefix("/")) } } } // Current behavior: Path traversal entries are NOT rejected // The script is outside the flows/ directory, so relativize produces "../scripts/outside.js" // This entry IS created in the ZIP (no validation/rejection happens) val hasTraversalEntry = entryNames.any { it.contains("..") && it.contains("scripts/outside.js") } val hasScriptEntry = entryNames.any { it.endsWith("outside.js") || it.endsWith("scripts/outside.js") } // Either the traversal path is preserved OR normalization resolves it - both are acceptable // The key point: NO rejection happens, the ZIP is created successfully assertThat(hasTraversalEntry || hasScriptEntry).isTrue() assertThat(entryNames.size).isAtLeast(2) // Should have at least main.yaml and the script } @Test fun `handles symlinks correctly`(@TempDir tempDir: Path) { // Layout: // tempDir/ // flows/main.yaml // scripts/real.js (actual file) // scripts/link.js -> real.js (symlink pointing to real.js) // The flow references link.js normally, but link.js is a symlink // This tests what happens when a dependency file is actually a symlink val flowsDir = tempDir.resolve("flows").toFile() flowsDir.mkdirs() val scriptsDir = tempDir.resolve("scripts").toFile() scriptsDir.mkdirs() val realScript = tempDir.resolve("scripts/real.js") Files.writeString(realScript, "console.log('real');") // Create symlink: link.js is a symlink that points to real.js val linkScript = tempDir.resolve("scripts/link.js") Files.createSymbolicLink(linkScript, realScript) val mainFlow = tempDir.resolve("flows/main.yaml") Files.writeString( mainFlow, """ appId: com.example.app --- - runScript: ../scripts/link.js """.trimIndent() ) val outZip = tempDir.resolve("workspace.zip") WorkspaceUtils.createWorkspaceZip(mainFlow, outZip) // With normalization using toRealPath(NOFOLLOW_LINKS), symlink paths are preserved // The ZIP should include the script (as link.js or normalized to real.js) val zipUri = URI.create("jar:${outZip.toUri()}") val entryNames = mutableListOf() FileSystems.newFileSystem(zipUri, emptyMap()).use { fs -> Files.walk(fs.getPath("/")).use { paths -> paths.filter { Files.isRegularFile(it) } .forEach { entryNames.add(it.toString().removePrefix("/")) } } } // Should have main.yaml and the script (either link.js or real.js, depending on normalization) assertThat(entryNames.size).isAtLeast(2) assertThat(entryNames.any { it.endsWith("main.yaml") }).isTrue() // Script should be included (either as link.js or normalized to real.js) assertThat(entryNames.any { it.contains("real.js") || it.contains("link.js") }).isTrue() } @Test fun `handles special characters in file paths`(@TempDir tempDir: Path) { // Test paths with spaces, unicode, and other special characters val mainFlow = tempDir.resolve("main.yaml") Files.writeString( mainFlow, """ appId: com.example.app --- - runScript: "scripts/script with spaces.js" - addMedia: - "images/émoji🎉.png" """.trimIndent() ) val scriptsDir = tempDir.resolve("scripts").toFile() scriptsDir.mkdirs() val scriptWithSpaces = tempDir.resolve("scripts/script with spaces.js") Files.writeString(scriptWithSpaces, "console.log('spaces');") val imagesDir = tempDir.resolve("images").toFile() imagesDir.mkdirs() val emojiFile = tempDir.resolve("images/émoji🎉.png") Files.writeString(emojiFile, "fake png") val outZip = tempDir.resolve("workspace.zip") WorkspaceUtils.createWorkspaceZip(mainFlow, outZip) // ZIP should be created successfully with special characters assertThat(outZip.toFile().exists()).isTrue() val zipUri = URI.create("jar:${outZip.toUri()}") val entryNames = mutableListOf() FileSystems.newFileSystem(zipUri, emptyMap()).use { fs -> Files.walk(fs.getPath("/")).use { paths -> paths.filter { Files.isRegularFile(it) } .forEach { entryNames.add(it.toString().removePrefix("/")) } } } // All files should be included assertThat(entryNames.size).isAtLeast(3) assertThat(entryNames.any { it.contains("script with spaces.js") }).isTrue() assertThat(entryNames.any { it.contains("émoji") || it.contains("🎉") }).isTrue() } @Test fun `handles empty files`(@TempDir tempDir: Path) { val mainFlow = tempDir.resolve("main.yaml") Files.writeString( mainFlow, """ appId: com.example.app --- - runScript: empty.js """.trimIndent() ) val emptyScript = tempDir.resolve("empty.js") Files.createFile(emptyScript) // Create empty file val outZip = tempDir.resolve("workspace.zip") WorkspaceUtils.createWorkspaceZip(mainFlow, outZip) // Should handle empty files gracefully assertThat(outZip.toFile().exists()).isTrue() val zipUri = URI.create("jar:${outZip.toUri()}") val entryNames = mutableListOf() FileSystems.newFileSystem(zipUri, emptyMap()).use { fs -> Files.walk(fs.getPath("/")).use { paths -> paths.filter { Files.isRegularFile(it) } .forEach { entryNames.add(it.toString().removePrefix("/")) } } } assertThat(entryNames.size).isAtLeast(2) assertThat(entryNames.any { it.endsWith("empty.js") }).isTrue() } } ================================================ FILE: maestro-cli/src/test/mcp/README.md ================================================ # MCP Testing Framework This directory contains testing infrastructure for Maestro's MCP (Model Context Protocol) server. ## Quick Start ```bash # Test tool functionality (API validation) ./run_mcp_tool_tests.sh ios # Test LLM behavior evaluations ./run_mcp_evals.sh ios ``` ## Testing Types ### Tool Functionality Tests (`run_mcp_tool_tests.sh`) - **Purpose**: Validate that MCP tools execute without errors and return expected data types - **Speed**: Fast (no complex setup required) - **Use case**: CI/CD gating, quick smoke tests during development ### LLM Behavior Evaluations (`run_mcp_evals.sh`) - **Purpose**: Validate that LLMs can properly use MCP tools to complete tasks - **Speed**: Slower (includes LLM reasoning evaluation) - **Use case**: Behavior validation, regression testing of LLM interactions ================================================ FILE: maestro-cli/src/test/mcp/full-evals.yaml ================================================ # yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json # MCP LLM Evaluations (evals) Test Configuration # Updated to current YAML format syntax evals: models: - claude-3-5-haiku-latest timeout: 30000 max_steps: 3 tests: - name: Lists all available tools prompt: Please list all available maestro tools you have access to. expected_tool_calls: allowed: [] response_scorers: - type: llm-judge criteria: > The assistant makes no tool calls and instead provides a list of all 14 available tools: list_devices, start_device, launch_app, take_screenshot, tap_on, input_text, back, stop_app, run_flow, run_flow_files, check_flow_syntax, inspect_view_hierarchy, cheat_sheet, and query_docs. The response should be comprehensive and not mention any other tool names. threshold: 1.0 - name: Lists all devices prompt: Please list all available devices for testing expected_tool_calls: required: - list_devices - name: Starts iOS device prompt: Start an iOS device for testing expected_tool_calls: required: - start_device response_scorers: - type: llm-judge criteria: Did the assistant correctly start an iOS device and mention the device ID or confirm successful startup? - name: Queries Maestro documentation prompt: How do I tap on an element with specific text in Maestro? expected_tool_calls: required: - query_docs allowed: - cheat_sheet response_scorers: - type: regex pattern: '(tap|tapOn|text)' - type: llm-judge criteria: > Did the assistant provide accurate information about tapping elements with text in Maestro, including proper syntax or examples? threshold: 0.8 - name: Validates flow syntax prompt: 'Check if this Maestro flow syntax is valid: ''- tapOn: "Submit button"''' expected_tool_calls: required: - check_flow_syntax allowed: - cheat_sheet response_scorers: - type: regex pattern: '(valid|invalid|syntax|error)' - name: Runs flow file by description prompt: Please run our "launch-safari-ios" flow file in the setup/flows folder expected_tool_calls: required: - run_flow_files ================================================ FILE: maestro-cli/src/test/mcp/inspect-view-hierarchy-evals.yaml ================================================ # yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json evals: models: - claude-3-7-sonnet-latest - claude-3-5-haiku-latest timeout: 40000 max_steps: 10 tests: - name: LLM can understand csv-based view hierarchy format prompt: > Using the inspect_view_hierarchy tool, describe the number of clickable elements and their position on the screen. Do not take a screenshot. expected_tool_calls: required: - inspect_view_hierarchy allowed: - list_devices - start_device - launch_app response_scorers: - type: llm-judge criteria: > The assistant successfully uses the inspect_view_hierarchy tool with format=csv. The assistant correctly identifies two buttons: "allow", and "don't allow" in the middle of the screen. threshold: 0.6 # NOTE: Below are old tests when we were doing evals on various format types. We might want to resuscitate these at some point # to make the format even more optimal, but for now we'll # - name: yaml_compact_format_comprehension # description: Test if LLM can understand and extract information from yaml compact view hierarchy format # prompt: > # Using the inspect_view_hierarchy tool with the "yaml" format, describe the number of clickable elements on the screen, and their position on the screen. Do not take a screenshot. # expectedToolCalls: # required: # - inspect_view_hierarchy # allowed: # - list_devices # - start_device # - launch_app # responseScorers: # - type: llm-judge # criteria: > # The assistant successfully uses the inspect_view_hierarchy tool with format=yaml. # The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner. # threshold: 1.0 # - name: csv_original_format_comprehension # description: Test if LLM can understand and extract information from csv compact view hierarchy format # prompt: > # Using the inspect_view_hierarchy tool with the "csv-original" format, describe the number of clickable elements and their position on the screen. Do not take a screenshot. # expectedToolCalls: # required: # - inspect_view_hierarchy # allowed: # - list_devices # - start_device # - launch_app # responseScorers: # - type: llm-judge # criteria: > # The assistant successfully uses the inspect_view_hierarchy tool. # The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner. # threshold: 1.0 # - name: json_compact_format_comprehension # description: Test if LLM can understand and extract information from json compact view hierarchy format # prompt: > # Using the inspect_view_hierarchy tool with the "json" format, describe the number of clickable elements and their position on the screen. Do not take a screenshot. # expectedToolCalls: # required: # - inspect_view_hierarchy # allowed: # - list_devices # - start_device # - launch_app # responseScorers: # - type: llm-judge # criteria: > # The assistant successfully uses the inspect_view_hierarchy tool with format=json. # The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner. # threshold: 0.8 # - name: schema_guided_navigation # description: Test if LLM can use the YAML compact schema to understand abbreviations # prompt: > # Use the yaml compact format to get the view hierarchy. Using the schema provided in the output, # find an element that has both text content and is clickable. Explain how you identified it. # expectedToolCalls: # required: # - inspect_view_hierarchy # responseScorers: # - type: llm-judge # criteria: > # The assistant uses inspect_view_hierarchy with yaml compact format, references the # ui_schema section to understand abbreviations, correctly identifies that 'txt' means text # and 'clickable: true' indicates an interactive element, and explains the process clearly. # threshold: 0.8 # - name: multi_format_comparison # description: Test LLM ability to work with multiple formats and choose the best one for a task # prompt: > # I want to get a quick overview of all interactive elements on the screen for automated testing. # Which format would be most suitable and why? Demonstrate by getting the hierarchy in that format. # expectedToolCalls: # required: # - inspect_view_hierarchy # responseScorers: # - type: llm-judge # criteria: > # The assistant analyzes the different format options, makes a reasoned choice # (likely yaml compact or json compact for efficiency, or CSV for structured processing), # explains the reasoning, and demonstrates by calling inspect_view_hierarchy with # the chosen format. # threshold: 0.8 # - name: error_handling_invalid_format # description: Test how LLM handles invalid format parameters # prompt: > # Try to get the view hierarchy using an invalid format parameter like "xml". # What happens and how would you handle this? # expectedToolCalls: # required: # - inspect_view_hierarchy # responseScorers: # - type: llm-judge # criteria: > # The assistant attempts inspect_view_hierarchy with an invalid format, # observes that it falls back to the default (yaml compact), and explains # that the tool has built-in error handling with sensible defaults. # threshold: 0.8 ================================================ FILE: maestro-cli/src/test/mcp/launch_app_with_env_replacement.yaml ================================================ appId: ${APP_ID} --- - launchApp ================================================ FILE: maestro-cli/src/test/mcp/maestro-mcp.json ================================================ { "mcpServers": { "maestro-mcp": { "command": "../../../build/install/maestro/bin/maestro", "args": [ "mcp", "--working-dir", "." ], "env": { "MAESTRO_API_KEY": "ADD_YOUR_API_KEY_HERE" } } } } ================================================ FILE: maestro-cli/src/test/mcp/mcp-server-config.json ================================================ { "mcpServers": { "maestro-mcp": { "command": "../../../../maestro-cli/build/install/maestro/bin/maestro", "args": [ "mcp", "--working-dir", "." ], "env": { "MAESTRO_API_KEY": "ADD_YOUR_API_KEY_HERE" } } } } ================================================ FILE: maestro-cli/src/test/mcp/run_mcp_evals.sh ================================================ #!/bin/bash set -e # Run MCP evaluation tests # # These tests validate MCP server behavior using LLM-based evaluations. # They test actual task completion and response quality. # # Usage: ./run_mcp_evals.sh [ios|android] platform="${1:-ios}" if [ "$platform" != "android" ] && [ "$platform" != "ios" ]; then echo "usage: $0 [ios|android]" exit 1 fi echo "🔧 Running MCP evaluation tests for $platform" # Get the script directory for relative paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Check if Maestro CLI is built "$SCRIPT_DIR/setup/check-maestro-cli-built.sh" # Run the evaluation tests (from mcp directory so paths work correctly) echo "🧪 Executing MCP evaluation tests..." cd "$SCRIPT_DIR" npx -y mcp-server-tester@1.3.1 evals full-evals.yaml --server-config maestro-mcp.json || true echo "✅ MCP evaluation tests completed!" ================================================ FILE: maestro-cli/src/test/mcp/run_mcp_tool_tests.sh ================================================ #!/bin/bash set -e # Run MCP tool functionality tests # # These tests validate that tools execute without errors and return expected data types. # They test the API functionality, not LLM behavior. # # Usage: ./run_tool_tests.sh [ios|android] platform="${1:-ios}" if [ "$platform" != "android" ] && [ "$platform" != "ios" ]; then echo "usage: $0 [ios|android]" exit 1 fi # Get the script directory for relative paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Check if Maestro CLI is built "$SCRIPT_DIR/setup/check-maestro-cli-built.sh" # Run the tests that do not require a simulator echo "🧪 Executing tool functionality tests that do not require a simulator..." npx -y mcp-server-tester@1.3.1 tools tool-tests-without-device.yaml --server-config maestro-mcp.json || true # Ensure simulator/emulator is running (required for tool tests) echo "🧪 Launching simulator..." DEVICE_ID=$("$SCRIPT_DIR/setup/launch-simulator.sh" "$platform") # Run the tool tests (from mcp directory so paths work correctly) echo "🧪 Executing tool functionality tests..." cd "$SCRIPT_DIR" DEVICE_ID="$DEVICE_ID" npx -y mcp-server-tester@1.3.1 tools tool-tests-with-device.yaml --server-config maestro-mcp.json || true echo "✅ Tool functionality tests completed!" ================================================ FILE: maestro-cli/src/test/mcp/setup/check-maestro-cli-built.sh ================================================ #!/bin/bash set -e # Check if Maestro CLI is built for MCP testing # # This script verifies that the Maestro CLI has been built and is available # at the expected location for MCP testing. # # Usage: ./check-maestro-cli-built.sh # Get the script directory and find the repo root SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" MAESTRO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" # Check if Maestro CLI is built MAESTRO_CLI_PATH="$MAESTRO_ROOT/maestro-cli/build/install/maestro/bin/maestro" if [ ! -f "$MAESTRO_CLI_PATH" ]; then echo "❌ Error: Maestro CLI not found at expected location." echo " MCP tests require the Maestro CLI to be built first." echo " Please run: ./gradlew :maestro-cli:installDist" echo " From the repository root: $MAESTRO_ROOT" exit 1 fi ================================================ FILE: maestro-cli/src/test/mcp/setup/download-and-install-apps.sh ================================================ #!/bin/bash set -e # Download and install apps for MCP testing # # Uses the existing e2e infrastructure to download and install test apps # on the specified platform. # # Usage: ./download-and-install-apps.sh platform="${1:-}" if [ "$platform" != "android" ] && [ "$platform" != "ios" ]; then echo "usage: $0 " exit 1 fi echo "📥 Setting up apps for MCP testing on $platform" # Get the script directory and find e2e directory SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" MAESTRO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" E2E_DIR="$MAESTRO_ROOT/e2e" # Check if we can find the e2e directory if [ ! -d "$E2E_DIR" ]; then echo "❌ Error: Could not find e2e directory at $E2E_DIR" exit 1 fi echo "📂 Using e2e directory: $E2E_DIR" # Step 1: Download apps using e2e infrastructure echo "📥 Downloading test apps..." cd "$E2E_DIR" ./download_apps # Step 2: Install apps for the specified platform echo "📱 Installing apps on $platform..." ./install_apps "$platform" echo "✅ Apps ready for MCP testing on $platform" ================================================ FILE: maestro-cli/src/test/mcp/setup/flows/launch-demo-app-ios.yaml ================================================ appId: com.example.example --- # Launch Demo App with clear state for evaluations - launchApp: appId: com.example.example clearState: true # Wait for app to load - waitForAnimationToEnd: timeout: 3000 ================================================ FILE: maestro-cli/src/test/mcp/setup/flows/launch-safari-ios.yaml ================================================ appId: com.apple.mobilesafari --- # Simple Safari launch for view hierarchy testing - launchApp: clearState: true # Wait for Safari to load - waitForAnimationToEnd: timeout: 2000 ================================================ FILE: maestro-cli/src/test/mcp/setup/flows/setup-wikipedia-search-android.yaml ================================================ appId: org.wikipedia --- # Navigate Wikipedia to search screen for view hierarchy testing # Assumes onboarding is already completed # Dismiss any auth modals that might appear - tapOn: text: "Continue without logging in" optional: true # Navigate to search to get an interesting hierarchy - tapOn: text: "Search Wikipedia" optional: true # Enter a search term to populate results - inputText: text: "Artificial Intelligence" optional: true # Wait for search results to load - waitForAnimationToEnd: timeout: 3000 # App should now show search results with rich hierarchy ================================================ FILE: maestro-cli/src/test/mcp/setup/flows/setup-wikipedia-search-ios.yaml ================================================ appId: org.wikimedia.wikipedia --- # Navigate Wikipedia to search screen for view hierarchy testing # Assumes onboarding is already completed # Dismiss any auth modals that might appear (from e2e advanced flow) - tapOn: text: "Continue without logging in" optional: true # Navigate to search to get an interesting hierarchy - tapOn: text: "Search Wikipedia" optional: true # Enter a search term to populate results - inputText: text: "Artificial Intelligence" optional: true # Wait for search results to load - waitForAnimationToEnd: timeout: 3000 # App should now show search results with rich hierarchy ================================================ FILE: maestro-cli/src/test/mcp/setup/flows/verify-ready-state.yaml ================================================ appId: org.wikimedia.wikipedia --- # Verify Wikipedia is in a good state for view hierarchy testing # Should be showing search results or main interface # Check that we can see some expected elements - assertVisible: text: "Search" optional: true # Or search results if we're on search page - assertVisible: text: "Intelligence" optional: true # Or main page elements - assertVisible: text: "Wikipedia" optional: true # Just ensure the app is responsive and loaded - waitForAnimationToEnd: timeout: 2000 ================================================ FILE: maestro-cli/src/test/mcp/setup/launch-simulator.sh ================================================ #!/bin/bash set -e # Launch simulator/emulator for MCP testing # # Checks if a simulator is running and launches if it's not running # # Usage: ./launch-simulator.sh platform="${1:-}" if [ "$platform" != "android" ] && [ "$platform" != "ios" ]; then echo "usage: $0 [--auto-launch]" exit 1 fi if [ "$platform" = "ios" ]; then if xcrun simctl list devices | grep -q "(Booted)"; then echo "✅ iOS simulator is already running" >&2 device_id=$(xcrun simctl list devices | grep "(Booted)" | head -1 | grep -o '[A-F0-9-]\{36\}') echo "$device_id" exit 0 fi # Find the first available iPhone simulator available_sim=$(xcrun simctl list devices | grep "iPhone" | grep -v "unavailable" | head -1 | sed 's/.*iPhone \([^(]*\).*/iPhone \1/' | sed 's/ *$//') if [ -n "$available_sim" ]; then echo "📱 Booting: $available_sim" >&2 device_id=$(xcrun simctl list devices | grep "iPhone" | grep -v "unavailable" | head -1 | grep -o '[A-F0-9-]\{36\}') xcrun simctl boot "$device_id" xcrun simctl bootstatus "$device_id" > /dev/null echo "✅ iOS simulator launched successfully" >&2 else echo "❌ Error: No available iOS simulators found" >&2 exit 1 fi elif [ "$platform" = "android" ]; then if adb devices | grep -q "device$"; then echo "✅ Android emulator/device is connected" >&2 device_id=$(adb devices | grep "device$" | head -1 | awk '{print $1}') elif [ "$auto_launch" = true ]; then echo "🚀 Auto-launching Android emulator not implemented yet" >&2 echo " Please start an Android emulator manually" >&2 exit 1 else echo "❌ No Android emulator/device is connected" >&2 echo " Please start an Android emulator first" >&2 echo " Or connect a physical device" >&2 exit 1 fi fi echo "$device_id" ================================================ FILE: maestro-cli/src/test/mcp/setup/setup_and_run_eval.sh ================================================ #!/bin/bash set -e # Run MCP LLM behavior evaluations # # These tests validate that LLMs can properly use MCP tools, including reasoning, # safety, and interaction patterns. They test client/server interaction and LLM capabilities. # # Usage: ./run_mcp_evals.sh [--app mobilesafari|wikipedia|demo_app] [eval-file2.yaml] [...] # Parse arguments app_setup="none" # Default to clean home screen eval_files=() while [[ $# -gt 0 ]]; do case $1 in --app) app_setup="$2" if [[ ! "$app_setup" =~ ^(none|mobilesafari|wikipedia|demo_app)$ ]]; then echo "Error: --app must be one of: none, mobilesafari, wikipedia, demo_app" exit 1 fi shift 2 ;; *.yaml) eval_files+=("$1") shift ;; *) echo "Unknown argument: $1" echo "usage: $0 [--app mobilesafari|wikipedia|demo_app] [eval-file2.yaml] [...]" exit 1 ;; esac done if [ ${#eval_files[@]} -eq 0 ]; then echo "❌ Error: No eval files provided" echo "usage: $0 [--app mobilesafari|wikipedia|demo_app] [eval-file2.yaml] [...]" echo " Default app setup: none (clean home screen)" echo "" echo "Available eval files:" find evals/ -name "*.yaml" 2>/dev/null | sed 's/^/ /' || echo " (none found)" exit 1 fi # Get the script directory for relative paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG="$SCRIPT_DIR/mcp-server-config.json" # Check if Maestro CLI is built "$SCRIPT_DIR/setup/check-maestro-cli-built.sh" # Ensure simulator is running (required for MCP evals that test device tools) platform="ios" "$SCRIPT_DIR/setup/launch-simulator.sh" "$platform" # App setup based on chosen option case "$app_setup" in "none") echo "📱 No app setup - using clean simulator home screen" ;; "mobilesafari") echo "📱 Launching Mobile Safari for evaluations..." cd "$(dirname "$SCRIPT_DIR")/../../.." maestro test "$SCRIPT_DIR/setup/flows/launch-safari-ios.yaml" echo "✅ Mobile Safari ready for evaluations" ;; "wikipedia") echo "📱 Setting up Wikipedia app environment for complex evaluations..." # Use setup utilities for app environment "$SCRIPT_DIR/setup/download-and-install-apps.sh" ios # Setup Wikipedia in a good state for hierarchy testing cd "$(dirname "$SCRIPT_DIR")/../../.." maestro test "$SCRIPT_DIR/setup/flows/setup-wikipedia-search-ios.yaml" maestro test "$SCRIPT_DIR/setup/flows/verify-ready-state.yaml" echo "✅ Wikipedia app environment ready for evaluations" ;; "demo_app") echo "📱 Launching Demo App for evaluations..." # Use setup utilities for app environment "$SCRIPT_DIR/setup/download-and-install-apps.sh" ios cd "$(dirname "$SCRIPT_DIR")/../../.." maestro test "$SCRIPT_DIR/setup/flows/launch-demo-app-ios.yaml" echo "✅ Demo App ready for evaluations" ;; esac # Run each eval file (from mcp directory so paths work correctly) cd "$SCRIPT_DIR" eval_count=0 for eval_file in "${eval_files[@]}"; do eval_count=$((eval_count + 1)) echo "📋 Running eval $eval_count: $eval_file" # Check if file exists, try relative to evals/ if not absolute if [ ! -f "$eval_file" ]; then if [ -f "evals/$eval_file" ]; then eval_file="evals/$eval_file" else echo "❌ Error: Eval file not found: $eval_file" exit 1 fi fi # Run the evals using MCP inspector npx -y mcp-server-tester@1.3.1 evals "$eval_file" --server-config "$CONFIG" done ================================================ FILE: maestro-cli/src/test/mcp/tool-tests-with-device.yaml ================================================ # yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json tools: tests: - name: "Test take_screenshot" tool: "take_screenshot" params: device_id: "${DEVICE_ID}" expect: success: true - name: "Test inspect_view_hierarchy" tool: "inspect_view_hierarchy" params: device_id: "${DEVICE_ID}" expect: success: true - name: "Test launch_app" tool: "launch_app" params: device_id: "${DEVICE_ID}" appId: "com.apple.mobilesafari" expect: success: true - name: "Test tap_on" tool: "tap_on" params: device_id: "${DEVICE_ID}" text: "Search" expect: success: true - name: "Test input_text" tool: "input_text" params: device_id: "${DEVICE_ID}" text: "hello" expect: success: true - name: "Test stop_app" tool: "stop_app" params: device_id: "${DEVICE_ID}" appId: "com.apple.mobilesafari" expect: success: true - name: "Test run_flow" tool: "run_flow" params: device_id: "${DEVICE_ID}" flow_yaml: | appId: com.apple.mobilesafari --- - launchApp expect: success: true - name: "Test run_flow_files (expect failure - nonexistent file)" tool: "run_flow_files" params: device_id: "${DEVICE_ID}" flow_files: "nonexistent.yaml" expect: success: false error: contains: "not found" - name: "Test run_flow_files with env replacement" tool: "run_flow_files" params: device_id: "${DEVICE_ID}" flow_files: "launch_app_with_env_replacement.yaml" env: APP_ID: "com.apple.mobilesafari" expect: success: true ================================================ FILE: maestro-cli/src/test/mcp/tool-tests-without-device.yaml ================================================ # yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json tools: expected_tool_list: - list_devices - start_device - launch_app - take_screenshot - tap_on - input_text - back - stop_app - run_flow - run_flow_files - check_flow_syntax - inspect_view_hierarchy - cheat_sheet - query_docs tests: - name: "List available devices" tool: "list_devices" params: {} expect: success: true result: contains: "device" - name: "Get Maestro cheat sheet (expect API key required)" tool: "cheat_sheet" params: {} expect: success: false error: contains: "MAESTRO_CLOUD_API_KEY" - name: "Query Maestro docs (expect API key required)" tool: "query_docs" params: question: "How do I tap on an element?" expect: success: false error: contains: "MAESTRO_CLOUD_API_KEY" - name: "Check valid flow syntax" tool: "check_flow_syntax" params: flow_yaml: | appId: com.apple.mobilesafari --- - tapOn: "Search" - inputText: "hello world" expect: success: true result: contains: "valid" - name: "Check invalid flow syntax" tool: "check_flow_syntax" params: flow_yaml: "invalid[yaml" expect: success: true result: contains: "invalid" - name: "Start iOS device" tool: "start_device" params: platform: "ios" expect: success: true result: contains: "device_id" ================================================ FILE: maestro-cli/src/test/resources/apps/web-manifest.json ================================================ {"url":"https://example.com"} ================================================ FILE: maestro-cli/src/test/resources/location/assert_multiple_locations.yaml ================================================ appId: "com.google.android.apps.maps" --- - launchApp - tapOn: text: Skip optional: true - setLocation: longitude: 2.295188 latitude: 48.8578065 - assertVisible: .*Eiffel.* - setLocation: latitude: 43.7230 longitude: 10.3966 - assertVisible: .*(Piazza|Pisa).* ================================================ FILE: maestro-cli/src/test/resources/travel/assert_travel_command.yaml ================================================ appId: "com.google.android.apps.maps" --- - launchApp - tapOn: text: Skip optional: true - setLocation: latitude: 48.8578065 longitude: 2.295188 - assertVisible: .*Eiffel.* - travel: points: - 48.8578065, 2.295188 - 46.2276, 5.9900 - 43.7230, 10.3966 - 41.8902, 12.4922 speed: 1000 - assertVisible: .*Colosseo.* ================================================ FILE: maestro-cli/src/test/resources/workspaces/cloud_test/android/flow.yaml ================================================ appId: com.example.maestro.orientation --- - launchApp ================================================ FILE: maestro-cli/src/test/resources/workspaces/cloud_test/ios/flow.yaml ================================================ appId: com.example.SimpleWebViewApp --- - launchApp ================================================ FILE: maestro-cli/src/test/resources/workspaces/cloud_test/tagged/regression.yaml ================================================ appId: com.example.SimpleWebViewApp tags: - regression --- - launchApp ================================================ FILE: maestro-cli/src/test/resources/workspaces/cloud_test/tagged/smoke.yaml ================================================ appId: com.example.SimpleWebViewApp tags: - smoke --- - launchApp ================================================ FILE: maestro-cli/src/test/resources/workspaces/cloud_test/web/flow.yaml ================================================ appId: https://example.com url: https://example.com --- - launchApp ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/mobileflow.yaml ================================================ appId: com.example.mobileapp --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/mobileflow2.yaml ================================================ appId: com.example.mobileapp2 --- - launchApp - tapOn: 'Submit' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/webflow.yaml ================================================ url: https://example.com --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/webflow2.yaml ================================================ url: https://example2.com --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/01_web_only/webflow.yaml ================================================ appId: com.example.webapp url: https://example.com --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/01_web_only/webflow2.yaml ================================================ url: https://example2.com --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/02_mobile_only/mobileflow1.yaml ================================================ appId: com.example.mobileapp1 --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/02_mobile_only/mobileflow2.yaml ================================================ appId: com.example.mobileapp2 --- - launchApp - tapOn: 'Submit' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/config.yaml ================================================ flows: - 'subFolder/*' includeTags: - tagNameToInclude excludeTags: - tagNameToExclude executionOrder: continueOnFailure: false flowsOrder: - mobileflow - mobileflow2 - webflow ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/mobileflow.yaml ================================================ appId: com.example.mobileapp name: mobileflow tags: - tagNameToInclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/mobileflow2.yaml ================================================ appId: com.example.mobileapp2 name: mobileflow2 properties: property-1: property1 value property-2: property2 value tags: - tagNameToInclude --- - launchApp - tapOn: 'Submit' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/webflow.yaml ================================================ url: https://example.com name: webflow tags: - tagNameToInclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/webflow2.yaml ================================================ url: https://example2.com name: webflow2 tags: - tagNameToExclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/config.yaml ================================================ flows: - 'subFolder/*' excludeTags: - tagNameToExclude executionOrder: continueOnFailure: false flowsOrder: - mobileflow - mobileflow2 ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/mobileflow.yaml ================================================ appId: com.example.mobileapp name: mobileflow tags: - tagNameToInclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/mobileflow2.yaml ================================================ appId: com.example.mobileapp2 name: mobileflow2 tags: - tagNameToInclude --- - launchApp - tapOn: 'Submit' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/webflow.yaml ================================================ url: https://example.com name: webflow tags: - tagNameToExclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/webflow2.yaml ================================================ url: https://example2.com name: webflow2 tags: - tagNameToExclude --- - launchApp - tapOn: 'Button' ================================================ FILE: maestro-client/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("maven-publish") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.mavenPublish) alias(libs.plugins.protobuf) } protobuf { protoc { artifact = "com.google.protobuf:protoc:${libs.versions.googleProtobuf.get()}" } plugins { create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}" } } generateProtoTasks { all().forEach { task -> task.plugins { create("grpc") } task.builtins { create("kotlin") } } } } tasks.named("compileKotlin") { dependsOn("generateProto") } tasks.named("processResources") { dependsOn(":maestro-android:copyMaestroAndroid") } tasks.whenTaskAdded { if (name == "sourcesJar" && this is Jar) { dependsOn(":maestro-android:copyMaestroAndroid") duplicatesStrategy = DuplicatesStrategy.EXCLUDE } } kotlin.sourceSets.all { // Prevent build warnings for grpc's generated opt-in code languageSettings.optIn("kotlin.RequiresOptIn") } sourceSets { main { java { srcDirs( "build/generated/source/proto/main/grpc", "build/generated/source/proto/main/java", "build/generated/source/proto/main/kotlin" ) } } } dependencies { protobuf(project(":maestro-proto")) implementation(project(":maestro-utils")) implementation(project(":maestro-ios-driver")) api(libs.graaljs) api(libs.graaljsEngine) api(libs.graaljsLanguage) api(libs.grpc.kotlin.stub) api(libs.grpc.stub) api(libs.grpc.netty) api(libs.grpc.protobuf) api(libs.grpc.okhttp) api(libs.google.protobuf.kotlin) api(libs.kotlin.result) api(libs.dadb) api(libs.square.okio) api(libs.square.okio.jvm) api(libs.image.comparison) api(libs.mozilla.rhino) api(libs.square.okhttp) api(libs.jarchivelib) api(libs.jackson.core.databind) api(libs.jackson.module.kotlin) api(libs.jackson.dataformat.yaml) api(libs.jackson.dataformat.xml) api(libs.apk.parser) implementation(project(":maestro-ios")) implementation(project(":maestro-web")) implementation(libs.google.findbugs) implementation(libs.axml) implementation(libs.selenium) implementation(libs.selenium.devtools) implementation(libs.jcodec) implementation(libs.datafaker) api(libs.logging.sl4j) api(libs.logging.api) api(libs.logging.layout.template) api(libs.log4j.core) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.google.truth) testImplementation(libs.square.mock.server) testImplementation(libs.junit.jupiter.params) } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } tasks.named("compileKotlin", KotlinCompilationTask::class.java) { compilerOptions { freeCompilerArgs.addAll("-Xjdk-release=17") } } mavenPublishing { publishToMavenCentral(true) signAllPublications() } tasks.named("test") { useJUnitPlatform() } ================================================ FILE: maestro-client/gradle.properties ================================================ POM_NAME=Maestro Client POM_ARTIFACT_ID=maestro-client POM_PACKAGING=jar ================================================ FILE: maestro-client/src/main/java/maestro/Bounds.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro data class Bounds( val x: Int, val y: Int, val width: Int, val height: Int ) { fun center(): Point { return Point( x = x + width / 2, y = y + height / 2 ) } fun area(): Int { return width * height } fun contains(x: Int, y: Int): Boolean { return x in this.x until this.x + width && y in this.y until this.y + height } } ================================================ FILE: maestro-client/src/main/java/maestro/Capability.kt ================================================ package maestro enum class Capability { FAST_HIERARCHY, } ================================================ FILE: maestro-client/src/main/java/maestro/DeviceInfo.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import maestro.device.Platform data class DeviceInfo( val platform: Platform, val widthPixels: Int, val heightPixels: Int, val widthGrid: Int, val heightGrid: Int, ) fun xcuitest.api.DeviceInfo.toCommonDeviceInfo(): DeviceInfo { return DeviceInfo( platform = Platform.IOS, widthPixels = widthPixels, heightPixels = heightPixels, widthGrid = widthPoints, heightGrid = heightPoints, ) } ================================================ FILE: maestro-client/src/main/java/maestro/Driver.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import maestro.device.DeviceOrientation import okio.Sink import java.io.File interface Driver { fun name(): String fun open() fun close() fun deviceInfo(): DeviceInfo fun launchApp( appId: String, launchArguments: Map, ) fun stopApp(appId: String) fun killApp(appId: String) fun clearAppState(appId: String) fun clearKeychain() fun tap(point: Point) fun longPress(point: Point) fun pressKey(code: KeyCode) fun contentDescriptor(excludeKeyboardElements: Boolean = false): TreeNode fun scrollVertical() fun isKeyboardVisible(): Boolean fun swipe(start: Point, end: Point, durationMs: Long) fun swipe(swipeDirection: SwipeDirection, durationMs: Long) fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) fun backPress() fun inputText(text: String) fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) fun hideKeyboard() fun takeScreenshot(out: Sink, compressed: Boolean) fun startScreenRecording(out: Sink): ScreenRecording fun setLocation(latitude: Double, longitude: Double) fun setOrientation(orientation: DeviceOrientation) fun eraseText(charactersToErase: Int) fun setProxy(host: String, port: Int) fun resetProxy() fun isShutdown(): Boolean fun isUnicodeInputSupported(): Boolean fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int? = null): ViewHierarchy? fun capabilities(): List fun setPermissions(appId: String, permissions: Map) fun addMedia(mediaFiles: List) fun isAirplaneModeEnabled(): Boolean fun setAirplaneMode(enabled: Boolean) fun setAndroidChromeDevToolsEnabled(enabled: Boolean) = Unit fun queryOnDeviceElements(query: OnDeviceElementQuery): List { return listOf() } } ================================================ FILE: maestro-client/src/main/java/maestro/Errors.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro sealed class MaestroException(override val message: String, cause: Throwable? = null) : RuntimeException(message, cause) { class UnableToLaunchApp(message: String, cause: Throwable? = null) : MaestroException(message, cause) class UnableToClearState(message: String, cause: Throwable? = null) : MaestroException(message, cause) class UnableToSetPermissions(message: String, cause: Throwable? = null) : MaestroException(message, cause) class AppCrash(message: String, cause: Throwable? = null): MaestroException(message, cause) class DriverTimeout(message: String, val debugMessage: String? = null, cause: Throwable? = null): MaestroException(message, cause) open class AssertionFailure( message: String, val hierarchyRoot: TreeNode, val debugMessage: String, cause: Throwable? = null, ) : MaestroException(message, cause) class ElementNotFound( message: String, hierarchyRoot: TreeNode, debugMessage: String, cause: Throwable? = null, ) : AssertionFailure(message, hierarchyRoot, debugMessage, cause) class CloudApiKeyNotAvailable(message: String, cause: Throwable? = null) : MaestroException(message, cause) class DestinationIsNotWritable(message: String, cause: Throwable? = null) : MaestroException(message, cause) class UnableToCopyTextFromElement(message: String, cause: Throwable? = null): MaestroException(message, cause) class InvalidCommand( message: String, cause: Throwable? = null, ) : MaestroException(message, cause) class HideKeyboardFailure(message: String, cause: Throwable? = null, val debugMessage: String) : MaestroException(message, cause) class NoRootAccess(message: String, cause: Throwable? = null) : MaestroException(message, cause) class UnsupportedJavaVersion(message: String, cause: Throwable? = null) : MaestroException(message, cause) class MissingAppleTeamId(message: String, cause: Throwable? = null): MaestroException(message, cause) class IOSDeviceDriverSetupException(message: String, cause: Throwable? = null): MaestroException(message, cause) } sealed class MaestroDriverStartupException(override val message: String, cause: Throwable? = null): RuntimeException(message, cause) { class AndroidDriverTimeoutException(message: String, cause: Throwable? = null): MaestroDriverStartupException(message, cause) class AndroidInstrumentationSetupFailure(message: String, cause: Throwable? = null): MaestroDriverStartupException(message, cause) } ================================================ FILE: maestro-client/src/main/java/maestro/Filters.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import maestro.UiElement.Companion.toUiElement import maestro.UiElement.Companion.toUiElementOrNull import kotlin.math.abs typealias ElementFilter = (List) -> List typealias ElementLookupPredicate = (TreeNode) -> Boolean object Filters { val INDEX_COMPARATOR: Comparator = compareBy( { it.toUiElementOrNull()?.bounds?.y ?: Int.MAX_VALUE }, { it.toUiElementOrNull()?.bounds?.x ?: Int.MAX_VALUE }, ) fun intersect(filters: List): ElementFilter = { nodes -> filters .map { it(nodes).toSet() } .reduceOrNull { a, b -> a.intersect(b) } ?.toList() ?: nodes } fun compose(first: ElementFilter, second: ElementFilter): ElementFilter = compose(listOf(first, second)) fun compose(filters: List): ElementFilter = { nodes -> filters .fold(nodes) { acc, filter -> filter(acc) } } fun ElementLookupPredicate.asFilter(): ElementFilter = { nodes -> nodes.filter { this(it) } } fun nonClickable(): ElementFilter { return { nodes -> nodes.filter { it.clickable == false } } } fun textMatches(regex: Regex): ElementFilter { return { nodes -> val textMatches = nodes.filter { it.attributes["text"]?.let { value -> val strippedValue = value.replace('\n', ' ') regex.matches(value) || regex.pattern == value || regex.matches(strippedValue) || regex.pattern == strippedValue } ?: false }.toSet() val hintTextMatches = nodes.filter { it.attributes["hintText"]?.let { value -> val strippedValue = value.replace('\n', ' ') regex.matches(value) || regex.pattern == value || regex.matches(strippedValue) || regex.pattern == strippedValue } ?: false } val accessibilityTextMatches = nodes.filter { it.attributes["accessibilityText"]?.let { value -> val strippedValue = value.replace('\n', ' ') regex.matches(value) || regex.pattern == value || regex.matches(strippedValue) || regex.pattern == strippedValue } ?: false }.toSet() textMatches.union(hintTextMatches).union(accessibilityTextMatches).toList() } } fun idMatches(regex: Regex): ElementFilter { return { nodes -> val exactMatches = nodes .filter { it.attributes["resource-id"]?.let { value -> regex.matches(value) } ?: false } .toSet() val idWithoutPrefixMatches = nodes .filter { it.attributes["resource-id"]?.let { value -> regex.matches(value.substringAfterLast('/')) } ?: false } .toSet() exactMatches .union(idWithoutPrefixMatches) .toList() } } fun sizeMatches( width: Int? = null, height: Int? = null, tolerance: Int? = null, ): ElementLookupPredicate { fun predicate(it: TreeNode): Boolean { if (it.attributes["bounds"] == null) { return false } val uiElement = it.toUiElement() val finalTolerance = tolerance ?: 0 if (width != null) { if (abs(uiElement.bounds.width - width) > finalTolerance) { return false } } if (height != null) { if (abs(uiElement.bounds.height - height) > finalTolerance) { return false } } return true } return { predicate(it) } } fun below(otherFilter: ElementFilter): ElementFilter { return relativeTo(otherFilter) { it, other -> it.bounds.y > other.bounds.y } } fun above(otherFilter: ElementFilter): ElementFilter { return relativeTo(otherFilter) { it, other -> it.bounds.y < other.bounds.y } } fun leftOf(otherFilter: ElementFilter): ElementFilter { return relativeTo(otherFilter) { it, other -> it.bounds.x < other.bounds.x } } fun rightOf(otherFilter: ElementFilter): ElementFilter { return relativeTo(otherFilter) { it, other -> it.bounds.x > other.bounds.x } } fun relativeTo(otherFilter: ElementFilter, predicate: (UiElement, UiElement) -> Boolean): ElementFilter { return { nodes -> val matchingOthers = otherFilter(nodes) .mapNotNull { it.toUiElementOrNull() } nodes .mapNotNull { it.toUiElementOrNull() } .flatMap { matchingOthers .filter { other -> predicate(it, other) } .map { other -> it to it.distanceTo(other) } } .sortedBy { (_, distance) -> distance } .map { (element, _) -> element.treeNode } } } fun containsChild(other: UiElement): ElementLookupPredicate { val otherNode = other.treeNode return { it.children .any { child -> child == otherNode } } } fun containsDescendants(filters: List): ElementFilter { fun ElementFilter.matches(node: TreeNode): Boolean { return invoke(listOf(node)).isNotEmpty() || node.children.any { matches(it) } } return { nodes -> nodes.filter { node -> filters.all { filter -> node.children.any { filter.matches(it) } } } } } fun hasText(): ElementLookupPredicate { return { it.attributes["text"] != null } } fun isSquare(): ElementLookupPredicate { return { it.toUiElementOrNull() ?.let { element -> abs(1.0f - (element.bounds.width / element.bounds.height.toFloat())) < 0.03f } ?: false } } fun hasLongText(): ElementLookupPredicate { return { (it.attributes["text"]?.length ?: 0) > 200 } } fun index(idx: Int): ElementFilter { return { nodes -> val sortedNodes = nodes.sortedWith(INDEX_COMPARATOR) val resolvedIndex = if (idx >= 0) idx else sortedNodes.size + idx if (resolvedIndex < 0) { emptyList() } else { listOfNotNull(sortedNodes.getOrNull(resolvedIndex)) } } } fun clickableFirst(): ElementFilter { return { nodes -> nodes.sortedByDescending { it.clickable } } } fun enabled(expected: Boolean): ElementFilter { return { nodes -> nodes.filter { it.enabled == expected } } } fun selected(expected: Boolean): ElementFilter { return { nodes -> nodes.filter { it.selected == expected } } } fun checked(expected: Boolean): ElementFilter { return { nodes -> nodes.filter { it.checked == expected } } } fun focused(expected: Boolean): ElementFilter { return { nodes -> nodes.filter { it.focused == expected } } } fun deepestMatchingElement(filter: ElementFilter): ElementFilter { return { nodes -> nodes.flatMap { node -> val matchingChildren = deepestMatchingElement(filter)(node.children) if (matchingChildren.isNotEmpty()) { matchingChildren } else if (filter(listOf(node)).isNotEmpty()) { listOf(node) } else { emptyList() } }.distinct() } } fun css(maestro: Maestro, cssSelector: String): ElementFilter { return { nodes -> val matchingNodes = maestro.findElementsByOnDeviceQuery( timeoutMs = 5000, query = OnDeviceElementQuery.Css(css = cssSelector), )?.elements?.map { it.treeNode } ?: emptyList() nodes.filter { node -> matchingNodes.any { it == node } } } } } ================================================ FILE: maestro-client/src/main/java/maestro/FindElementResult.kt ================================================ package maestro data class FindElementResult(val element: UiElement, val hierarchy: ViewHierarchy) ================================================ FILE: maestro-client/src/main/java/maestro/KeyCode.kt ================================================ package maestro enum class KeyCode( val description: String, ) { ENTER("Enter"), BACKSPACE("Backspace"), BACK("Back"), HOME("Home"), LOCK("Lock"), VOLUME_UP("Volume Up"), VOLUME_DOWN("Volume Down"), REMOTE_UP("Remote Dpad Up"), REMOTE_DOWN("Remote Dpad Down"), REMOTE_LEFT("Remote Dpad Left"), REMOTE_RIGHT("Remote Dpad Right"), REMOTE_CENTER("Remote Dpad Center"), REMOTE_PLAY_PAUSE("Remote Media Play Pause"), REMOTE_STOP("Remote Media Stop"), REMOTE_NEXT("Remote Media Next"), REMOTE_PREVIOUS("Remote Media Previous"), REMOTE_REWIND("Remote Media Rewind"), REMOTE_FAST_FORWARD("Remote Media Fast Forward"), ESCAPE("Escape"), POWER("Power"), TAB("Tab"), REMOTE_SYSTEM_NAVIGATION_UP("Remote System Navigation Up"), REMOTE_SYSTEM_NAVIGATION_DOWN("Remote System Navigation Down"), REMOTE_BUTTON_A("Remote Button A"), REMOTE_BUTTON_B("Remote Button B"), REMOTE_MENU("Remote Menu"), TV_INPUT("TV Input"), TV_INPUT_HDMI_1("TV Input HDMI 1"), TV_INPUT_HDMI_2("TV Input HDMI 2"), TV_INPUT_HDMI_3("TV Input HDMI 3"); companion object { fun getByName(name: String): KeyCode? { val lowercaseName = name.lowercase() return values().find { it.description.lowercase() == lowercaseName } } } } ================================================ FILE: maestro-client/src/main/java/maestro/Maestro.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import com.github.romankh3.image.comparison.ImageComparison import maestro.UiElement.Companion.toUiElementOrNull import maestro.device.DeviceOrientation import maestro.drivers.CdpWebDriver import maestro.utils.MaestroTimer import maestro.utils.ScreenshotUtils import maestro.utils.SocketUtils import okio.Buffer import okio.Sink import okio.buffer import okio.sink import okio.use import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO import kotlin.system.measureTimeMillis @Suppress("unused", "MemberVisibilityCanBePrivate") class Maestro( val driver: Driver, ) : AutoCloseable { val deviceName: String get() = driver.name() val cachedDeviceInfo by lazy { LOGGER.info("Getting device info") val deviceInfo = driver.deviceInfo() LOGGER.info("Got device info: $deviceInfo") deviceInfo } @Deprecated("This function should be removed and its usages refactored. See issue #2031") fun deviceInfo() = driver.deviceInfo() private var screenRecordingInProgress = false fun launchApp( appId: String, launchArguments: Map = emptyMap(), stopIfRunning: Boolean = true ) { LOGGER.info("Launching app $appId") if (stopIfRunning) { LOGGER.info("Stopping $appId app during launch") driver.stopApp(appId) } driver.launchApp(appId, launchArguments) } fun stopApp(appId: String) { LOGGER.info("Stopping app $appId") driver.stopApp(appId) } fun killApp(appId: String) { LOGGER.info("Killing app $appId") driver.killApp(appId) } fun clearAppState(appId: String) { LOGGER.info("Clearing app state $appId") driver.clearAppState(appId) } fun setPermissions(appId: String, permissions: Map) { driver.setPermissions(appId, permissions) } fun clearKeychain() { LOGGER.info("Clearing keychain") driver.clearKeychain() } fun backPress() { LOGGER.info("Pressing back") driver.backPress() waitForAppToSettle() } fun hideKeyboard() { LOGGER.info("Hiding Keyboard") driver.hideKeyboard() } fun isKeyboardVisible(): Boolean { return driver.isKeyboardVisible() } fun swipe( swipeDirection: SwipeDirection? = null, startPoint: Point? = null, endPoint: Point? = null, startRelative: String? = null, endRelative: String? = null, duration: Long, waitToSettleTimeoutMs: Int? = null ) { val deviceInfo = deviceInfo() when { swipeDirection != null -> driver.swipe(swipeDirection, duration) startPoint != null && endPoint != null -> driver.swipe(startPoint, endPoint, duration) startRelative != null && endRelative != null -> { val startPoints = startRelative.replace("%", "") .split(",").map { it.trim().toInt() } val startX = deviceInfo.widthGrid * startPoints[0] / 100 val startY = deviceInfo.heightGrid * startPoints[1] / 100 val start = Point(startX, startY) val endPoints = endRelative.replace("%", "") .split(",").map { it.trim().toInt() } val endX = deviceInfo.widthGrid * endPoints[0] / 100 val endY = deviceInfo.heightGrid * endPoints[1] / 100 val end = Point(endX, endY) driver.swipe(start, end, duration) } } waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs) } fun swipe(swipeDirection: SwipeDirection, uiElement: UiElement, durationMs: Long, waitToSettleTimeoutMs: Int?) { LOGGER.info("Swiping ${swipeDirection.name} on element: $uiElement") driver.swipe(uiElement.bounds.center(), swipeDirection, durationMs) waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs) } fun swipeFromCenter(swipeDirection: SwipeDirection, durationMs: Long, waitToSettleTimeoutMs: Int?) { val deviceInfo = deviceInfo() LOGGER.info("Swiping ${swipeDirection.name} from center") val center = Point(x = deviceInfo.widthGrid / 2, y = deviceInfo.heightGrid / 2) driver.swipe(center, swipeDirection, durationMs) waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs) } fun scrollVertical() { LOGGER.info("Scrolling vertically") driver.scrollVertical() waitForAppToSettle() } fun tap( element: UiElement, initialHierarchy: ViewHierarchy, retryIfNoChange: Boolean = false, waitUntilVisible: Boolean = false, longPress: Boolean = false, appId: String? = null, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { LOGGER.info("Tapping on element: ${tapRepeat ?: ""} $element") val hierarchyBeforeTap = waitForAppToSettle(initialHierarchy, appId, waitToSettleTimeoutMs) ?: initialHierarchy val center = ( hierarchyBeforeTap .refreshElement(element.treeNode) ?.also { LOGGER.info("Refreshed element") } ?.toUiElementOrNull() ?: element ).bounds .center() performTap( x = center.x, y = center.y, retryIfNoChange = retryIfNoChange, longPress = longPress, initialHierarchy = hierarchyBeforeTap, tapRepeat = tapRepeat, waitToSettleTimeoutMs = waitToSettleTimeoutMs ) if (waitUntilVisible) { val hierarchyAfterTap = viewHierarchy() if (hierarchyBeforeTap == hierarchyAfterTap && !hierarchyAfterTap.isVisible(element.treeNode) ) { LOGGER.info("Still no change in hierarchy. Wait until element is visible and try again.") val hierarchy = waitUntilVisible(element) tap( element = element, initialHierarchy = hierarchy, retryIfNoChange = false, waitUntilVisible = false, longPress = longPress, tapRepeat = tapRepeat ) } } } fun tapOnRelative( percentX: Int, percentY: Int, retryIfNoChange: Boolean = false, longPress: Boolean = false, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { val deviceInfo = driver.deviceInfo() val x = deviceInfo.widthGrid * percentX / 100 val y = deviceInfo.heightGrid * percentY / 100 tap( x = x, y = y, retryIfNoChange = retryIfNoChange, longPress = longPress, tapRepeat = tapRepeat, waitToSettleTimeoutMs = waitToSettleTimeoutMs ) } fun tap( x: Int, y: Int, retryIfNoChange: Boolean = false, longPress: Boolean = false, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { performTap( x = x, y = y, retryIfNoChange = retryIfNoChange, longPress = longPress, tapRepeat = tapRepeat, waitToSettleTimeoutMs = waitToSettleTimeoutMs ) } private fun getNumberOfRetries(retryIfNoChange: Boolean): Int { return if (retryIfNoChange) 2 else 1 } private fun performTap( x: Int, y: Int, retryIfNoChange: Boolean = false, longPress: Boolean = false, initialHierarchy: ViewHierarchy? = null, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { val capabilities = driver.capabilities() if (Capability.FAST_HIERARCHY in capabilities) { hierarchyBasedTap(x, y, retryIfNoChange, longPress, initialHierarchy, tapRepeat, waitToSettleTimeoutMs) } else { screenshotBasedTap(x, y, retryIfNoChange, longPress, initialHierarchy, tapRepeat, waitToSettleTimeoutMs) } } private fun hierarchyBasedTap( x: Int, y: Int, retryIfNoChange: Boolean = false, longPress: Boolean = false, initialHierarchy: ViewHierarchy? = null, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { LOGGER.info("Tapping at ($x, $y) using hierarchy based logic for wait") val hierarchyBeforeTap = initialHierarchy ?: viewHierarchy() val retries = getNumberOfRetries(retryIfNoChange) repeat(retries) { if (longPress) { driver.longPress(Point(x, y)) } else if (tapRepeat != null) { for (i in 0 until tapRepeat.repeat) { // subtract execution duration from tap delay val duration = measureTimeMillis { driver.tap(Point(x, y)) } val delay = if (duration >= tapRepeat.delay) 0 else tapRepeat.delay - duration if (tapRepeat.repeat > 1) Thread.sleep(delay) // do not wait for single taps } } else driver.tap(Point(x, y)) val hierarchyAfterTap = waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs) if (hierarchyAfterTap == null || hierarchyBeforeTap != hierarchyAfterTap) { LOGGER.info("Something has changed in the UI judging by view hierarchy. Proceed.") return } } } private fun screenshotBasedTap( x: Int, y: Int, retryIfNoChange: Boolean = false, longPress: Boolean = false, initialHierarchy: ViewHierarchy? = null, tapRepeat: TapRepeat? = null, waitToSettleTimeoutMs: Int? = null ) { LOGGER.info("Try tapping at ($x, $y) using hierarchy based logic for wait") val hierarchyBeforeTap = initialHierarchy ?: viewHierarchy() val screenshotBeforeTap: BufferedImage? = ScreenshotUtils.tryTakingScreenshot(driver) val retries = getNumberOfRetries(retryIfNoChange) repeat(retries) { if (longPress) { driver.longPress(Point(x, y)) } else if (tapRepeat != null) { for (i in 0 until tapRepeat.repeat) { // subtract execution duration from tap delay val duration = measureTimeMillis { driver.tap(Point(x, y)) } val delay = if (duration >= tapRepeat.delay) 0 else tapRepeat.delay - duration if (tapRepeat.repeat > 1) Thread.sleep(delay) // do not wait for single taps } } else { driver.tap(Point(x, y)) } val hierarchyAfterTap = waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs) if (hierarchyBeforeTap != hierarchyAfterTap) { LOGGER.info("Something have changed in the UI judging by view hierarchy. Proceed.") return } LOGGER.info("Tapping at ($x, $y) using screenshot based logic for wait") val screenshotAfterTap: BufferedImage? = ScreenshotUtils.tryTakingScreenshot(driver) if (screenshotBeforeTap != null && screenshotAfterTap != null && screenshotBeforeTap.width == screenshotAfterTap.width && screenshotBeforeTap.height == screenshotAfterTap.height ) { val imageDiff = ImageComparison( screenshotBeforeTap, screenshotAfterTap ).compareImages().differencePercent if (imageDiff > SCREENSHOT_DIFF_THRESHOLD) { LOGGER.info("Something have changed in the UI judging by screenshot (d=$imageDiff). Proceed.") return } else { LOGGER.info("Screenshots are not different enough (d=$imageDiff)") } } else { LOGGER.info("Skipping screenshot comparison") } LOGGER.info("Nothing changed in the UI.") } } private fun waitUntilVisible(element: UiElement): ViewHierarchy { var hierarchy = ViewHierarchy(TreeNode()) repeat(10) { hierarchy = viewHierarchy() if (!hierarchy.isVisible(element.treeNode)) { LOGGER.info("Element is not visible yet. Waiting.") MaestroTimer.sleep(MaestroTimer.Reason.WAIT_UNTIL_VISIBLE, 1000) } else { LOGGER.info("Element became visible.") return hierarchy } } return hierarchy } fun pressKey(code: KeyCode, waitForAppToSettle: Boolean = true) { LOGGER.info("Pressing key $code") driver.pressKey(code) if (waitForAppToSettle) { waitForAppToSettle() } } fun viewHierarchy(excludeKeyboardElements: Boolean = false): ViewHierarchy { return ViewHierarchy.from(driver, excludeKeyboardElements) } fun findElementWithTimeout( timeoutMs: Long, filter: ElementFilter, viewHierarchy: ViewHierarchy? = null ): FindElementResult? { var hierarchy = viewHierarchy ?: ViewHierarchy(TreeNode()) val element = MaestroTimer.withTimeout(timeoutMs) { hierarchy = viewHierarchy ?: viewHierarchy() filter(hierarchy.aggregate()).firstOrNull() }?.toUiElementOrNull() return if (element == null) { null } else { if (viewHierarchy != null) { hierarchy = ViewHierarchy(element.treeNode) } return FindElementResult(element, hierarchy) } } fun findElementsByOnDeviceQuery( timeoutMs: Long, query: OnDeviceElementQuery ): OnDeviceElementQueryResult? { return MaestroTimer.withTimeout(timeoutMs) { val elements = driver.queryOnDeviceElements(query) OnDeviceElementQueryResult( elements = elements.mapNotNull { it.toUiElementOrNull() }, ) } } fun allElementsMatching(filter: ElementFilter): List { return filter(viewHierarchy().aggregate()) } fun waitForAppToSettle( initialHierarchy: ViewHierarchy? = null, appId: String? = null, waitToSettleTimeoutMs: Int? = null ): ViewHierarchy? { return driver.waitForAppToSettle(initialHierarchy, appId, waitToSettleTimeoutMs) } fun inputText(text: String) { LOGGER.info("Inputting text: $text") driver.inputText(text) waitForAppToSettle() } fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) { LOGGER.info("Opening link $link for app: $appId with autoVerify config as $autoVerify") driver.openLink(link, appId, autoVerify, browser) waitForAppToSettle() } fun addMedia(fileNames: List) { val mediaFiles = fileNames.map { File(it) } driver.addMedia(mediaFiles) } override fun close() { driver.close() } @Deprecated("Use takeScreenshot(Sink, Boolean) instead") fun takeScreenshot(outFile: File, compressed: Boolean) { LOGGER.info("Taking screenshot to a file: $outFile") val absoluteOutFile = outFile.absoluteFile if (absoluteOutFile.parentFile.exists() || absoluteOutFile.parentFile.mkdirs()) { outFile .sink() .buffer() .use { ScreenshotUtils.takeScreenshot(it, compressed, driver) } } else { throw MaestroException.DestinationIsNotWritable( "Failed to create directory for screenshot: ${absoluteOutFile.parentFile}" ) } } fun takeScreenshot(sink: Sink, compressed: Boolean, bounds: Bounds? = null) { if (bounds == null) { LOGGER.info("Taking screenshot") sink .buffer() .use { ScreenshotUtils.takeScreenshot(it, compressed, driver) } } else { LOGGER.info("Taking screenshot (cropped to bounds)") val (x, y, width, height) = bounds val originalImage = Buffer().apply { ScreenshotUtils.takeScreenshot(this, compressed, driver) }.let { buffer -> buffer.inputStream().use { ImageIO.read(it) } } val info = cachedDeviceInfo val scale = if (info.heightGrid > 0) { info.heightPixels.toDouble() / info.heightGrid } else { 1.0 } val startX = (x * scale).toInt().coerceIn(0, originalImage.width) val startY = (y * scale).toInt().coerceIn(0, originalImage.height) val cropWidthPx = (width * scale).toInt() .coerceIn(0, originalImage.width - startX) val cropHeightPx = (height * scale).toInt() .coerceIn(0, originalImage.height - startY) if (cropWidthPx <= 0 || cropHeightPx <= 0) { throw MaestroException.AssertionFailure( message = "Cannot crop screenshot: invalid dimensions (width: $cropWidthPx, height: $cropHeightPx).", hierarchyRoot = viewHierarchy(excludeKeyboardElements = false).root, debugMessage = "Bounds (grid units) x=$x, y=$y, width=$width, height=$height with scale=$scale produced non-positive crop size." ) } val croppedImage = originalImage.getSubimage( startX, startY, cropWidthPx, cropHeightPx ) sink .buffer() .use { ImageIO.write(croppedImage, "png", it.outputStream()) } } } fun startScreenRecording(out: Sink): ScreenRecording { LOGGER.info("Starting screen recording") if (screenRecordingInProgress) { LOGGER.info("Screen recording not started: Already in progress") return object : ScreenRecording { override fun close() { // No-op } } } screenRecordingInProgress = true LOGGER.info("Starting screen recording") val screenRecording = driver.startScreenRecording(out) val startTimestamp = System.currentTimeMillis() return object : ScreenRecording { override fun close() { LOGGER.info("Stopping screen recording") // Ensure minimum screen recording duration of 3 seconds. // This addresses an edge case where the launch command completes too quickly. val durationPadding = 3000 - (System.currentTimeMillis() - startTimestamp) if (durationPadding > 0) { Thread.sleep(durationPadding) } screenRecording.close() screenRecordingInProgress = false } } } fun setLocation(latitude: String, longitude: String) { LOGGER.info("Setting location: ($latitude, $longitude)") driver.setLocation(latitude.toDouble(), longitude.toDouble()) } fun setOrientation(orientation: DeviceOrientation, waitForAppToSettle: Boolean = true) { LOGGER.info("Setting orientation: $orientation") driver.setOrientation(orientation) if (waitForAppToSettle) { waitForAppToSettle() } } fun eraseText(charactersToErase: Int) { LOGGER.info("Erasing $charactersToErase characters") driver.eraseText(charactersToErase) } fun waitForAnimationToEnd(timeout: Long?) { @Suppress("NAME_SHADOWING") val timeout = timeout ?: ANIMATION_TIMEOUT_MS LOGGER.info("Waiting for animation to end with timeout $timeout") ScreenshotUtils.waitUntilScreenIsStatic(timeout, SCREENSHOT_DIFF_THRESHOLD, driver) } fun setProxy( host: String = SocketUtils.localIp(), port: Int ) { LOGGER.info("Setting proxy: $host:$port") driver.setProxy(host, port) } fun resetProxy() { LOGGER.info("Resetting proxy") driver.resetProxy() } fun isShutDown(): Boolean { return driver.isShutdown() } fun isUnicodeInputSupported(): Boolean { return driver.isUnicodeInputSupported() } fun isAirplaneModeEnabled(): Boolean { return driver.isAirplaneModeEnabled() } fun setAirplaneModeState(enabled: Boolean) { driver.setAirplaneMode(enabled) } fun setAndroidChromeDevToolsEnabled(enabled: Boolean) { driver.setAndroidChromeDevToolsEnabled(enabled) } companion object { private val LOGGER = LoggerFactory.getLogger(Maestro::class.java) private const val SCREENSHOT_DIFF_THRESHOLD = 0.005 // 0.5% private const val ANIMATION_TIMEOUT_MS: Long = 15000 fun ios(driver: Driver, openDriver: Boolean = true): Maestro { if (openDriver) { driver.open() } return Maestro(driver) } fun android(driver: Driver, openDriver: Boolean = true): Maestro { if (openDriver) { driver.open() } return Maestro(driver) } fun web( isStudio: Boolean, isHeadless: Boolean, screenSize: String?, ): Maestro { // Check that JRE is at least 11 val version = System.getProperty("java.version") if (version.startsWith("1.")) { val majorVersion = version.substring(2, 3).toInt() if (majorVersion < 11) { throw MaestroException.UnsupportedJavaVersion( "Maestro Web requires Java 11 or later. Current version: $version" ) } } val driver = CdpWebDriver( isStudio = isStudio, isHeadless = isHeadless, screenSize = screenSize, ) driver.open() return Maestro(driver) } } } ================================================ FILE: maestro-client/src/main/java/maestro/Media.kt ================================================ package maestro import okio.Source class NamedSource(val name: String, val source: Source, val extension: String, val path: String) enum class MediaExt(val extName: String) { PNG("png"), JPEG("jpeg"), JPG("jpg"), GIF("gif"), MP4("mp4"), } ================================================ FILE: maestro-client/src/main/java/maestro/OnDeviceElementQuery.kt ================================================ package maestro sealed class OnDeviceElementQuery { data class Css( val css: String, ) : OnDeviceElementQuery() } ================================================ FILE: maestro-client/src/main/java/maestro/OnDeviceElementQueryResult.kt ================================================ package maestro data class OnDeviceElementQueryResult( val elements: List, ) ================================================ FILE: maestro-client/src/main/java/maestro/Point.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import kotlin.math.pow import kotlin.math.sqrt fun distance( fromX: T, fromY: T, toX: T, toY: T ): Float { return sqrt( (fromX.toDouble() - toX.toDouble()).pow(2.0) + (fromY.toDouble() - toY.toDouble()).pow(2.0) ).toFloat() } data class Point( val x: Int, val y: Int ) { fun distance(other: Point): Float { return distance(x, y, other.x, other.y) } } ================================================ FILE: maestro-client/src/main/java/maestro/ScreenRecording.kt ================================================ package maestro interface ScreenRecording : AutoCloseable ================================================ FILE: maestro-client/src/main/java/maestro/ScrollDirection.kt ================================================ package maestro enum class ScrollDirection { UP, DOWN, RIGHT, LEFT } fun ScrollDirection.toSwipeDirection(): SwipeDirection = when (this) { ScrollDirection.DOWN -> SwipeDirection.UP ScrollDirection.UP -> SwipeDirection.DOWN ScrollDirection.LEFT -> SwipeDirection.RIGHT ScrollDirection.RIGHT -> SwipeDirection.LEFT } ================================================ FILE: maestro-client/src/main/java/maestro/SwipeDirection.kt ================================================ package maestro enum class SwipeDirection { UP, DOWN, RIGHT, LEFT } inline fun > directionValueOfOrNull(input: String): SwipeDirection? { return enumValues().find { it.name == input || it.name.lowercase() == input } } ================================================ FILE: maestro-client/src/main/java/maestro/TapRepeat.kt ================================================ package maestro data class TapRepeat( val repeat: Int, val delay: Long // millis ) ================================================ FILE: maestro-client/src/main/java/maestro/TreeNode.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro data class TreeNode( val attributes: MutableMap = mutableMapOf(), val children: List = emptyList(), val clickable: Boolean? = null, val enabled: Boolean? = null, val focused: Boolean? = null, val checked: Boolean? = null, val selected: Boolean? = null, ) { fun aggregate(): List { return listOf(this) + children.flatMap { it.aggregate() } } } ================================================ FILE: maestro-client/src/main/java/maestro/UiElement.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro data class UiElement( val treeNode: TreeNode, val bounds: Bounds, ) { fun distanceTo(other: UiElement): Float { return bounds.center().distance(other.bounds.center()) } fun getVisiblePercentage(screenWidth: Int, screenHeight: Int): Double { if (bounds.width == 0 && bounds.height == 0) { return 0.0 } val overflow = (bounds.x <= 0) && (bounds.y <= 0) && (bounds.x + bounds.width >= screenWidth) && (bounds.y + bounds.height >= screenHeight) if (overflow) { return 1.0 } val visibleX = maxOf(0, minOf(bounds.x + bounds.width, screenWidth) - maxOf(bounds.x, 0)) val visibleY = maxOf(0, minOf(bounds.y + bounds.height, screenHeight) - maxOf(bounds.y, 0)) val visibleArea = visibleX * visibleY val totalArea = bounds.width * bounds.height return visibleArea.toDouble() / totalArea.toDouble() } fun isElementNearScreenCenter(direction: SwipeDirection, screenWidth: Int, screenHeight: Int): Boolean { val centerX = screenWidth / 2 val centerY = screenHeight / 2 val elementCenterX = bounds.x + (bounds.width / 2) val elementCenterY = bounds.y + (bounds.height / 2) val margin = when(direction) { SwipeDirection.DOWN, SwipeDirection.UP -> screenHeight / 5 SwipeDirection.LEFT, SwipeDirection.RIGHT -> screenWidth / 5 } // return true when the element center is within the half of the screen bounds plus margin return when(direction) { SwipeDirection.RIGHT -> elementCenterX > centerX - margin SwipeDirection.LEFT -> elementCenterX < centerX + margin SwipeDirection.UP -> elementCenterY < centerY + margin SwipeDirection.DOWN -> elementCenterY > centerY - margin } } fun isWithinViewPortBounds(info: DeviceInfo, paddingHorizontal: Float = 0f, paddingVertical: Float = 0f): Boolean { val paddingX = (info.widthGrid * paddingHorizontal).toInt() val paddingY = (info.heightGrid * paddingVertical).toInt() val xEnd = info.widthGrid - paddingX val yEnd = info.heightGrid - paddingY val isXWithinBounds = bounds.x in paddingX..xEnd val isYWithinBounds = bounds.y in paddingY..yEnd return isXWithinBounds && isYWithinBounds } companion object { fun TreeNode.toUiElement(): UiElement { return toUiElementOrNull() ?: throw IllegalStateException("Node has no bounds") } fun TreeNode.toUiElementOrNull(): UiElement? { // TODO needs different impl for iOS val boundsStr = attributes["bounds"] ?: return null val boundsArr = boundsStr .replace("][", ",") .removePrefix("[") .removeSuffix("]") .split(",") .map { it.toInt() } return UiElement( this, Bounds( x = boundsArr[0], y = boundsArr[1], width = boundsArr[2] - boundsArr[0], height = boundsArr[3] - boundsArr[1] ), ) } } } ================================================ FILE: maestro-client/src/main/java/maestro/ViewHierarchy.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro import maestro.UiElement.Companion.toUiElement @JvmInline value class ViewHierarchy(val root: TreeNode) { companion object { fun from(driver: Driver, excludeKeyboardElements: Boolean): ViewHierarchy { val deviceInfo = driver.deviceInfo() val root = driver.contentDescriptor(excludeKeyboardElements).let { val filtered = it.filterOutOfBounds( width = deviceInfo.widthGrid, height = deviceInfo.heightGrid ) filtered ?: it } return ViewHierarchy(root) } } fun isVisible(node: TreeNode): Boolean { if (!node.attributes.containsKey("bounds")) { return false } val center = node.toUiElement().bounds.center() val elementAtPosition = getElementAt(root, center.x, center.y) return node == elementAtPosition } fun refreshElement(node: TreeNode): TreeNode? { val matches = root.aggregate() .filter { (it.attributes - "bounds") == (node.attributes - "bounds") } if (matches.size != 1) { return null } return matches[0] } fun getElementAt( node: TreeNode, x: Int, y: Int ): TreeNode? { return node .children .asReversed() .asSequence() .mapNotNull { val elementWithinChild = if (it.children.isNotEmpty()) { getElementAt(it, x, y) } else { null } elementWithinChild ?: if (it.attributes.containsKey("bounds")) { val bounds = it.toUiElement().bounds if (bounds.contains(x, y)) { it } else { null } } else { null } } .firstOrNull() } fun aggregate(): List { return root.aggregate() } } fun TreeNode.filterOutOfBounds(width: Int, height: Int): TreeNode? { if (attributes.containsKey("ignoreBoundsFiltering") && attributes["ignoreBoundsFiltering"] == "true") { return this } val filtered = children.mapNotNull { it.filterOutOfBounds(width, height) }.toList() // parent can have missing bounds val element = kotlin.runCatching { toUiElement() }.getOrNull() val visiblePercentage = element?.getVisiblePercentage(width, height) ?: 0.0 if (visiblePercentage < 0.1 && filtered.isEmpty()) { return null } return TreeNode( attributes = attributes, children = filtered, clickable = clickable, enabled = enabled, focused = focused, checked = checked, selected = selected ) } ================================================ FILE: maestro-client/src/main/java/maestro/android/AndroidAppFiles.kt ================================================ package maestro.android import dadb.Dadb import java.io.File import java.io.IOException import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.createDirectories object AndroidAppFiles { fun pull(dadb: Dadb, packageName: String, zipOutFile: File) { zipOutFile.delete() val zipOutUri = URI("jar:${zipOutFile.toURI().scheme}", zipOutFile.absolutePath, null) FileSystems.newFileSystem(zipOutUri, mapOf("create" to "true")).use { fs -> // Create zip directories first listRemoteFiles(dadb, packageName, "-type d").forEach { remoteDir -> val dstLocation = remoteDir .removePrefix("/") .removeSuffix("/.") .removeSuffix("/") val dstPath = fs.getPath(dstLocation) dstPath.createDirectories() } // Create zip files listRemoteFiles(dadb, packageName, "-type f").forEach { remoteFile -> val dstLocation = remoteFile .removePrefix("/") .removeSuffix("/.") .removeSuffix("/") val dstPath = fs.getPath(dstLocation) pullAppFile(dadb, packageName, dstPath, remoteFile) } } } fun getApkFile(dadb: Dadb, appId: String): File { val apkPath = dadb.shell("pm list packages -f --user 0 | grep $appId | head -1") .output.substringAfterLast("package:").substringBefore("=$appId") apkPath.substringBefore("=$appId") val dst = File.createTempFile("tmp", ".apk") dadb.pull(dst, apkPath) return dst } fun push(dadb: Dadb, packageName: String, appFilesZip: File) { val remoteZip = "/data/local/tmp/app.zip" dadb.push(appFilesZip, remoteZip) try { shell(dadb, "run-as $packageName unzip -o -d / $remoteZip") } finally { shell(dadb, "rm $remoteZip") } } private fun pullAppFile(dadb: Dadb, packageName: String, localPath: Path, remotePath: String) { dadb.open("exec:run-as $packageName cat $remotePath").use { stream -> Files.copy(stream.source.inputStream(), localPath) } } private fun listRemoteFiles(dadb: Dadb, packageName: String, options: String): List { val result = shell(dadb, "run-as $packageName find $options") val appDataDir = "/data/data/$packageName" return result.lines() .filter { it.isNotBlank() } .map { "$appDataDir/${it.removePrefix("./")}" } } private fun shell(dadb: Dadb, command: String): String { val response = dadb.shell(command) if (response.exitCode != 0) throw IOException("Shell command failed ($command):\n${response.allOutput}") return response.allOutput } } ================================================ FILE: maestro-client/src/main/java/maestro/android/AndroidBuildToolsDirectory.kt ================================================ package maestro.android import java.io.File import java.util.regex.Pattern internal object AndroidBuildToolsDirectory { fun findBuildToolsDir(androidHome: File): File { val buildToolsParent = File(androidHome, "build-tools") if (!buildToolsParent.exists()) { throw IllegalStateException("build-tools directory does not exist: $buildToolsParent") } val latestBuildToolsVersion = getLatestToolsVersion(buildToolsParent) ?: throw IllegalStateException("Could not find a valid build-tools subdirectory in $buildToolsParent") return File(buildToolsParent, latestBuildToolsVersion.toString()) } private fun getLatestToolsVersion(buildToolsParent: File): BuildToolsVersion? { return buildToolsParent.listFiles()!! .mapNotNull { BuildToolsVersion.parse(it.name) } .maxOfOrNull { it } } private class BuildToolsVersion( val major: Int, val minor: Int, val patch: Int, ) : Comparable { override fun toString(): String { return "$major.$minor.$patch" } override fun compareTo(other: BuildToolsVersion): Int { return VERSION_COMPARATOR.compare(this, other) } companion object { private val VERSION_PATTERN = Pattern.compile("([0-9]+)\\.([0-9]+)\\.([0-9]+)") private val VERSION_COMPARATOR = compareBy( { v -> v.major }, { v -> v.minor }, { v -> v.patch }, ) fun parse(name: String): BuildToolsVersion? { val m = VERSION_PATTERN.matcher(name) if (!m.matches()) return null return BuildToolsVersion( major = m.group(1).toInt(), minor = m.group(2).toInt(), patch = m.group(3).toInt(), ) } } } } ================================================ FILE: maestro-client/src/main/java/maestro/android/AndroidLaunchArguments.kt ================================================ package maestro.android import maestro_android.MaestroAndroid object AndroidLaunchArguments { fun Map.toAndroidLaunchArguments(): List { return toList().map { when (val value = it.second) { is Boolean -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(Boolean::class.java.name) .build() is Int -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(Int::class.java.name) .build() is Double -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(Double::class.java.name) .build() is Long -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(Long::class.java.name) .build() is String -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(String::class.java.name) .build() else -> MaestroAndroid.ArgumentValue.newBuilder() .setKey(it.first) .setValue(value.toString()) .setType(String::class.java.name) .build() } } } } ================================================ FILE: maestro-client/src/main/java/maestro/android/chromedevtools/AndroidWebViewHierarchyClient.kt ================================================ package maestro.android.chromedevtools import dadb.Dadb import maestro.Bounds import maestro.TreeNode import maestro.UiElement import maestro.UiElement.Companion.toUiElementOrNull import java.io.Closeable class AndroidWebViewHierarchyClient(dadb: Dadb): Closeable { private val devToolsClient = DadbChromeDevToolsClient(dadb) fun augmentHierarchy(baseHierarchy: TreeNode, chromeDevToolsEnabled: Boolean): TreeNode { if (!chromeDevToolsEnabled) return baseHierarchy if (!hasWebView(baseHierarchy)) return baseHierarchy // TODO: Adapt to handle chrome in the same way val webViewHierarchy = devToolsClient.getWebViewTreeNodes() val merged = mergeHierarchies(baseHierarchy, webViewHierarchy) return merged } override fun close() { devToolsClient.close() } companion object { fun mergeHierarchies(baseHierarchy: TreeNode, webViewHierarchy: List): TreeNode { if (webViewHierarchy.isEmpty()) return baseHierarchy val newNodes = mutableListOf() val baseNodes = baseHierarchy.aggregate().mapNotNull { it.toUiElementOrNull() } // We can use a quadtree here if this is too slow val webViewNodes = webViewHierarchy.flatMap { it.aggregate() }.filter { it.attributes["text"]?.isNotBlank() == true || it.attributes["resource-id"]?.isNotBlank() == true || it.attributes["hintText"]?.isNotBlank() == true || it.attributes["accessibilityText"]?.isNotBlank() == true }.mapNotNull { it.toUiElementOrNull() }.filter { it.bounds.width > 0 && it.bounds.height > 0 } webViewNodes.forEach { webViewNode -> if (!baseNodes.any { webViewNode.mergeWith(it) }) { newNodes.add(webViewNode.treeNode) } } if (newNodes.isEmpty()) return baseHierarchy return TreeNode(children = listOf(baseHierarchy) + newNodes) } private fun UiElement.mergeWith(base: UiElement): Boolean { if (!this.bounds.intersects(base.bounds)) return false val thisTexts = this.treeNode.texts() val baseTexts = base.treeNode.texts() val thisId = this.treeNode.attributes["resource-id"] val baseId = base.treeNode.attributes["resource-id"] // web view text is a substring of base text val mergeableText = thisTexts.any { baseTexts.any { baseText -> baseText.contains(it) } } // web view id matches base id val mergeableId = thisId?.isNotEmpty() == true && baseId?.isNotEmpty() == true && thisId == baseId // web view id matches base text val mergeableId2 = baseTexts.any { it == thisId } if (!mergeableText && !mergeableId && !mergeableId2) return false val newAttributes = this.treeNode.attributes newAttributes.remove("bounds") if (baseTexts.isNotEmpty()) { newAttributes.remove("text") newAttributes.remove("hintText") newAttributes.remove("accessibilityText") } if (baseId?.isNotEmpty() == true) newAttributes.remove("resource-id") newAttributes.entries.removeIf { it.value.isEmpty() } base.treeNode.attributes += newAttributes return true } private fun TreeNode.texts(): List { return listOfNotNull(attributes["text"], attributes["hintText"], attributes["accessibilityText"]).filter { it.isNotEmpty() } } private fun Bounds.intersects(other: Bounds): Boolean { return this.x < other.x + other.width && this.x + this.width > other.x && this.y < other.y + other.height && this.y + this.height > other.y } private fun hasWebView(node: TreeNode): Boolean { if (isWebView(node)) return true for (child in node.children) { if (hasWebView(child)) return true } return false } private fun isWebView(node: TreeNode): Boolean { return node.attributes["class"] == "android.webkit.WebView" } } } ================================================ FILE: maestro-client/src/main/java/maestro/android/chromedevtools/DadbChromeDevToolsClient.kt ================================================ package maestro.android.chromedevtools import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dadb.Dadb import maestro.Maestro import maestro.TreeNode import maestro.utils.HttpClient import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.use import org.slf4j.LoggerFactory import java.io.Closeable import java.io.IOException import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import kotlin.system.measureTimeMillis private data class RuntimeResponse( val result: RemoteObject ) private data class RemoteObject( val type: String, val value: T, ) data class WebViewInfo( val socketName: String, val webSocketDebuggerUrl: String, val visible: Boolean, val attached: Boolean, val empty: Boolean, val screenX: Int, val screenY: Int, val width: Int, val height: Int, ) private data class WebViewResponse( val description: String, val webSocketDebuggerUrl: String, ) private data class WebViewDescription( val visible: Boolean, val attached: Boolean, val empty: Boolean, val screenX: Int, val screenY: Int, val width: Int, val height: Int, ) private data class DevToolsResponse( val id: Int, val result: T, ) class DadbChromeDevToolsClient(private val dadb: Dadb): Closeable { private val json = jacksonObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false) private val okhttp = HttpClient.build("DadbChromeDevToolsClient").newBuilder() .dadb(dadb) .build() private val script = Maestro::class.java.getResourceAsStream("/maestro-web.js")?.let { it.bufferedReader().use { br -> br.readText() } } ?: error("Could not read maestro web script") override fun close() { okhttp.dispatcher.executorService.shutdown() okhttp.connectionPool.evictAll() okhttp.cache?.close() } fun getWebViewTreeNodes(): List { return getWebViewInfos() .filter { it.visible } .mapNotNull { info -> try { evaluateScript>(info.socketName, info.webSocketDebuggerUrl, "$script; maestro.viewportX = ${info.screenX}; maestro.viewportY = ${info.screenY}; maestro.viewportWidth = ${info.width}; maestro.viewportHeight = ${info.height}; window.maestro.getContentDescription();").result.value } catch (e: IOException) { logger.warn("Failed to retrieve WebView hierarchy from chrome devtools: ${info.socketName} ${info.webSocketDebuggerUrl}", e) null } } } inline fun evaluateScript(socketName: String, webSocketDebuggerUrl: String, script: String) = makeRequest( socketName = socketName, webSocketDebuggerUrl = webSocketDebuggerUrl, method = "Runtime.evaluate", params = mapOf( "expression" to script, "returnByValue" to true, ), ) inline fun makeRequest(socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T { val resultTypeReference = object : TypeReference() {} return makeRequest(resultTypeReference, socketName, webSocketDebuggerUrl, method, params) } fun makeRequest(resultTypeReference: TypeReference, socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T { val request = json.writeValueAsString(mapOf("id" to 1, "method" to method, "params" to params)) val url = webSocketDebuggerUrl.replace("ws", "http").toHttpUrl().newBuilder() .host("localabstract.$socketName.adb") .build() val response = makeSingleWebsocketRequest(url, request) return try { val resultType = TypeFactory.defaultInstance().constructType(resultTypeReference) val responseType = TypeFactory.defaultInstance() .constructParametricType(DevToolsResponse::class.java, resultType) json.readValue>(response, responseType).result } catch (e: JsonProcessingException) { throw IOException("Failed to parse DOM snapshot: $response", e) } } fun getWebViewInfos(): List { return getWebViewSocketNames().flatMap(::getWebViewInfos) } fun makeSingleWebsocketRequest(url: HttpUrl, message: String): String { val future = CompletableFuture() val ws = okhttp.newWebSocket( Request.Builder() .url(url) .build(), object : WebSocketListener() { override fun onMessage(webSocket: WebSocket, text: String) { future.complete(text) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { future.completeExceptionally(t) } } ) ws.send(message) val response = try { future.get(5, TimeUnit.SECONDS) } catch (_: TimeoutException) { throw TimeoutException("Timed out waiting for websocket response") } catch (e: ExecutionException) { throw e.cause ?: e } ws.close(1000, null) return response } private fun getWebViewInfos(socketName: String): List { val url = "http://localabstract.$socketName.adb/json" val call = okhttp.newCall(Request.Builder() .url(url) .header("Host", "localhost:9222") // Expected by devtools server .build()) val response = try { call.execute() } catch (e: IOException) { logger.error("IOException while getting WebView info from $url. Defaulting to empty list.", e) return emptyList() } if (response.code != 200) { logger.error("Request to get WebView infos failed with code ${response.code}. Defaulting to empty list.") return emptyList() } val body = response.body?.string() ?: throw IllegalStateException("No body found") return try { json.readValue>(body).mapNotNull { parsed -> // Description is empty for eg. service workers if (parsed.description.isBlank()) return@mapNotNull null val description = json.readValue(parsed.description, WebViewDescription::class.java) WebViewInfo( socketName = socketName, webSocketDebuggerUrl = parsed.webSocketDebuggerUrl, visible = description.visible, attached = description.attached, empty = description.empty, screenX = description.screenX, screenY = description.screenY, width = description.width, height = description.height, ) }.filter { it.attached && it.visible && !it.empty } } catch (e: JsonProcessingException) { throw IllegalStateException("Failed to parse WebView chrome dev tools response:\n$body", e) } } private fun getWebViewSocketNames(): Set { val response = dadb.shell("cat /proc/net/unix") if (response.exitCode != 0) { throw IllegalStateException("Failed get WebView socket names. Command 'cat /proc/net/unix' failed: ${response.allOutput}") } return response.allOutput.trim().lines().mapNotNull { line -> line.split(Regex("\\s+")).lastOrNull()?.takeIf { it.startsWith(WEB_VIEW_SOCKET_PREFIX) }?.substring(1) }.toSet() } companion object { private const val WEB_VIEW_SOCKET_PREFIX = "@webview_devtools_remote_" private val logger = LoggerFactory.getLogger(Maestro::class.java) } } fun main() { (Dadb.discover() ?: throw IllegalStateException("No devices found")).use { dadb -> DadbChromeDevToolsClient(dadb).apply { while (true) { measureTimeMillis { println(getWebViewTreeNodes().size) }.also { println("time: $it") } } } } } ================================================ FILE: maestro-client/src/main/java/maestro/android/chromedevtools/DadbSocket.kt ================================================ package maestro.android.chromedevtools import dadb.AdbStream import dadb.Dadb import okhttp3.Dns import okhttp3.OkHttpClient import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.SocketAddress import java.net.SocketException import java.nio.channels.SocketChannel import javax.net.SocketFactory fun OkHttpClient.Builder.dadb(dadb: Dadb): OkHttpClient.Builder { dns(DadbDns()) socketFactory(DadbSocketFactory(dadb)) return this } class DadbDns : Dns { override fun lookup(hostname: String): List { if (!hostname.endsWith(".adb")) throw IllegalArgumentException("Invalid hostname. Eg. tcp.8000.adb, localabstract.chrome_devtools_remote.adb") return listOf(InetAddress.getByAddress(hostname, byteArrayOf(0, 0, 0, 0))) } } class DadbSocketFactory(private val dadb: Dadb) : SocketFactory() { override fun createSocket(): Socket { return DadbStreamSocket(dadb) } override fun createSocket(host: String?, port: Int): Socket { throw UnsupportedOperationException("Not implemented") } override fun createSocket( host: String?, port: Int, localHost: InetAddress?, localPort: Int ): Socket { throw UnsupportedOperationException("Not implemented") } override fun createSocket(host: InetAddress?, port: Int): Socket { throw UnsupportedOperationException("Not implemented") } override fun createSocket( address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int ): Socket { throw UnsupportedOperationException("Not implemented") } } class DadbStreamSocket(private val dadb: Dadb) : Socket() { private var closed = false private var adbStream: AdbStream? = null override fun getInputStream(): InputStream { val adbStream = this.adbStream ?: throw SocketException("Socket is not connected") return adbStream.source.inputStream() } override fun getOutputStream(): OutputStream { val adbStream = this.adbStream ?: throw SocketException("Socket is not connected") return object : FilterOutputStream(adbStream.sink.outputStream()) { override fun write(b: ByteArray) { super.write(b) flush() } override fun write(b: ByteArray, off: Int, len: Int) { super.write(b, off, len) flush() } } } override fun connect(endpoint: SocketAddress, timeout: Int) { if (endpoint !is InetSocketAddress) throw UnsupportedOperationException("Endpoint must be a InetSocketAddress: $endpoint (${endpoint::class})") val destination = endpoint.hostName.removeSuffix(".adb").replace(".", ":") this.adbStream = dadb.open(destination) } override fun isClosed() = closed override fun close() { if (isClosed()) return adbStream?.close() adbStream = null closed = true } override fun setSoTimeout(timeout: Int) = Unit override fun isInputShutdown() = false override fun isOutputShutdown() = false override fun connect(endpoint: SocketAddress?) { throw UnsupportedOperationException("Not implemented") } override fun bind(bindpoint: SocketAddress?) { throw UnsupportedOperationException("Not implemented") } override fun getInetAddress(): InetAddress { throw UnsupportedOperationException("Not implemented") } override fun getLocalAddress(): InetAddress { throw UnsupportedOperationException("Not implemented") } override fun getPort(): Int { throw UnsupportedOperationException("Not implemented") } override fun getLocalPort(): Int { throw UnsupportedOperationException("Not implemented") } override fun getRemoteSocketAddress(): SocketAddress { throw UnsupportedOperationException("Not implemented") } override fun getLocalSocketAddress(): SocketAddress { throw UnsupportedOperationException("Not implemented") } override fun getChannel(): SocketChannel { throw UnsupportedOperationException("Not implemented") } override fun setTcpNoDelay(on: Boolean) { throw UnsupportedOperationException("Not implemented") } override fun getTcpNoDelay(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun setSoLinger(on: Boolean, linger: Int) { throw UnsupportedOperationException("Not implemented") } override fun getSoLinger(): Int { throw UnsupportedOperationException("Not implemented") } override fun sendUrgentData(data: Int) { throw UnsupportedOperationException("Not implemented") } override fun setOOBInline(on: Boolean) { throw UnsupportedOperationException("Not implemented") } override fun getOOBInline(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun getSoTimeout(): Int { throw UnsupportedOperationException("Not implemented") } override fun setSendBufferSize(size: Int) { throw UnsupportedOperationException("Not implemented") } override fun getSendBufferSize(): Int { throw UnsupportedOperationException("Not implemented") } override fun setReceiveBufferSize(size: Int) { throw UnsupportedOperationException("Not implemented") } override fun getReceiveBufferSize(): Int { throw UnsupportedOperationException("Not implemented") } override fun setKeepAlive(on: Boolean) { throw UnsupportedOperationException("Not implemented") } override fun getKeepAlive(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun setTrafficClass(tc: Int) { throw UnsupportedOperationException("Not implemented") } override fun getTrafficClass(): Int { throw UnsupportedOperationException("Not implemented") } override fun setReuseAddress(on: Boolean) { throw UnsupportedOperationException("Not implemented") } override fun getReuseAddress(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun shutdownInput() { throw UnsupportedOperationException("Not implemented") } override fun shutdownOutput() { throw UnsupportedOperationException("Not implemented") } override fun isConnected(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun isBound(): Boolean { throw UnsupportedOperationException("Not implemented") } override fun setPerformancePreferences(connectionTime: Int, latency: Int, bandwidth: Int) { throw UnsupportedOperationException("Not implemented") } } ================================================ FILE: maestro-client/src/main/java/maestro/auth/ApiKey.kt ================================================ package maestro.auth import java.nio.file.Paths import kotlin.io.path.deleteIfExists import kotlin.io.path.exists import kotlin.io.path.isDirectory import kotlin.io.path.readText class ApiKey { companion object { private val cachedAuthTokenFile by lazy { Paths.get( System.getProperty("user.home"), ".mobiledev", "authtoken" ) } private fun maestroCloudApiKey(): String? { return System.getenv("MAESTRO_CLOUD_API_KEY") } private fun getCachedAuthToken(): String? { if (!cachedAuthTokenFile.exists()) return null if (cachedAuthTokenFile.isDirectory()) return null val cachedAuthToken = cachedAuthTokenFile.readText() return cachedAuthToken } fun getToken(): String? { return maestroCloudApiKey() ?: // Resolve API key from shell if set getCachedAuthToken() // Otherwise, if the user has already logged in, use the cached auth token } fun setToken(token: String?) { cachedAuthTokenFile.parent.toFile().mkdirs() if (token == null) { cachedAuthTokenFile.deleteIfExists() return } cachedAuthTokenFile.toFile().writeText(token) } } } ================================================ FILE: maestro-client/src/main/java/maestro/debuglog/DebugLogStore.kt ================================================ package maestro.debuglog import maestro.Driver import maestro.utils.FileUtils import net.harawata.appdirs.AppDirsFactory import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Date import java.util.Properties import java.util.logging.ConsoleHandler import java.util.logging.FileHandler import java.util.logging.Level import java.util.logging.LogRecord import java.util.logging.Logger import java.util.logging.SimpleFormatter object DebugLogStore { private const val APP_NAME = "maestro" private const val APP_AUTHOR = "mobile_dev" private const val LOG_DIR_DATE_FORMAT = "yyyy-MM-dd_HHmmss" private const val KEEP_LOG_COUNT = 6 val logDirectory = File(AppDirsFactory.getInstance().getUserLogDir(APP_NAME, null, APP_AUTHOR)) private val currentRunLogDirectory: File private val consoleHandler: ConsoleHandler private val fileHandler: FileHandler init { val dateFormatter = DateTimeFormatter.ofPattern(LOG_DIR_DATE_FORMAT) val date = dateFormatter.format(LocalDateTime.now()) currentRunLogDirectory = File(logDirectory, date) currentRunLogDirectory.mkdirs() removeOldLogs(logDirectory) consoleHandler = ConsoleHandler() consoleHandler.level = Level.WARNING consoleHandler.formatter = object : SimpleFormatter() { override fun format(record: LogRecord): String { val level = if (record.level.intValue() > 900) "Error: " else "" return "$level${record.message}\n" } } val maestroLogFile = logFile("maestro") fileHandler = FileHandler(maestroLogFile.absolutePath) fileHandler.level = Level.ALL fileHandler.formatter = object : SimpleFormatter() { private val format = "[%1\$tF %1\$tT] [%2$-7s] %3\$s %n" @Suppress("DefaultLocale") @Synchronized override fun format(lr: LogRecord): String { return java.lang.String.format( format, Date(lr.millis), lr.level.localizedName, lr.message ) } } } fun copyTo(file: File) { val local = logFile("maestro") local.copyTo(file) } fun loggerFor(clazz: Class<*>): Logger { val logger = Logger.getLogger(clazz.name) logger.useParentHandlers = false logger.addHandler(consoleHandler) logger.addHandler(fileHandler) return logger } fun logOutputOf(processBuilder: ProcessBuilder) { val command = processBuilder.command().first() ?: "unknown" val logFile = logFile(command) val redirect = ProcessBuilder.Redirect.to(logFile) processBuilder .redirectOutput(redirect) .redirectError(redirect) } fun finalizeRun() { fileHandler.close() val output = File(currentRunLogDirectory.parent, "${currentRunLogDirectory.name}.zip") FileUtils.zipDir(currentRunLogDirectory.toPath(), output.toPath()) currentRunLogDirectory.deleteRecursively() } private fun logFile(named: String): File { return File(currentRunLogDirectory, "$named.log") } private fun removeOldLogs(baseDir: File) { if (!baseDir.isDirectory) { return } val existing = baseDir.listFiles() ?: return val toDelete = existing.sortedByDescending { it.name } .drop(KEEP_LOG_COUNT) .toList() toDelete.forEach { it.deleteRecursively() } } fun logSystemInfo() { val logData = """ Maestro version: ${appVersion()} OS: ${System.getProperty("os.name")} OS version: ${System.getProperty("os.version")} Architecture: ${System.getProperty("os.arch")} """.trimIndent() + "\n" logFile("system_info").writeText(logData) } private fun appVersion(): String { try { val props = Driver::class.java.classLoader.getResourceAsStream("version.properties").use { Properties().apply { load(it) } } return props["version"].toString() } catch (ignore: Exception) { // no-action } return "Undefined" } } fun Logger.warn(message: String, throwable: Throwable? = null) { if (throwable != null) { log(Level.WARNING, message, throwable) } else log(Level.WARNING, message) } fun Logger.error(message: String, throwable: Throwable? = null) { if (throwable != null) { log(Level.SEVERE, message, throwable) } else log(Level.SEVERE, message) } ================================================ FILE: maestro-client/src/main/java/maestro/debuglog/LogConfig.kt ================================================ package maestro.debuglog import org.apache.logging.log4j.core.appender.FileAppender import org.apache.logging.log4j.core.config.Configurator import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration object LogConfig { private const val DEFAULT_FILE_LOG_PATTERN = "%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n" private const val DEFAULT_CONSOLE_LOG_PATTERN = "%highlight([%5level]) %msg%n" private val FILE_LOG_PATTERN: String = System.getenv("MAESTRO_CLI_LOG_PATTERN_FILE") ?: DEFAULT_FILE_LOG_PATTERN private val CONSOLE_LOG_PATTERN: String = System.getenv("MAESTRO_CLI_LOG_PATTERN_CONSOLE") ?: DEFAULT_CONSOLE_LOG_PATTERN fun configure(logFileName: String? = null, printToConsole: Boolean) { val builder = ConfigurationBuilderFactory.newConfigurationBuilder() builder.setStatusLevel(org.apache.logging.log4j.Level.ERROR) builder.setConfigurationName("MaestroConfig") // Disable ktor logging completely builder.add( builder.newLogger("io.ktor", org.apache.logging.log4j.Level.OFF) .addAttribute("additivity", false) ) val rootLogger = builder.newRootLogger(org.apache.logging.log4j.Level.ALL) if (logFileName != null) { val fileAppender = createFileAppender(builder, logFileName) rootLogger.add(builder.newAppenderRef(fileAppender.getName())) } if (printToConsole) { val consoleAppender = createConsoleAppender(builder) rootLogger.add(builder.newAppenderRef(consoleAppender.getName())) } builder.add(rootLogger) val config = builder.build() Configurator.reconfigure(config) } private fun createConsoleAppender(builder: ConfigurationBuilder): org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder { val consoleAppender = builder.newAppender("Console", "CONSOLE") val consoleLayout = builder.newLayout("PatternLayout") consoleLayout.addAttribute("pattern", CONSOLE_LOG_PATTERN) consoleAppender.add(consoleLayout) builder.add(consoleAppender) return consoleAppender } private fun createFileAppender(builder: ConfigurationBuilder, logFileName: String): org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder { val fileAppender = builder.newAppender("File", FileAppender.PLUGIN_NAME) fileAppender.addAttribute("fileName", logFileName) val fileLayout = builder.newLayout("PatternLayout") fileLayout.addAttribute("pattern", FILE_LOG_PATTERN) fileAppender.add(fileLayout) builder.add(fileAppender) return fileAppender } } ================================================ FILE: maestro-client/src/main/java/maestro/device/Device.kt ================================================ package maestro.device sealed class Device( open val description: String, open val platform: Platform, open val deviceType: DeviceType, open val deviceSpec: DeviceSpec ) { enum class DeviceType { REAL, SIMULATOR, EMULATOR, BROWSER } data class Connected( val instanceId: String, override val deviceSpec: DeviceSpec, override val description: String, override val platform: Platform, override val deviceType: DeviceType, ) : Device(description, platform, deviceType, deviceSpec) data class AvailableForLaunch( val modelId: String, override val deviceSpec: DeviceSpec, override val description: String, override val platform: Platform, override val deviceType: DeviceType, ) : Device(description, platform, deviceType, deviceSpec) } ================================================ FILE: maestro-client/src/main/java/maestro/device/DeviceError.kt ================================================ package maestro.device /** * Exception class specifically for device-related errors in the client module. * Functionally equivalent to CliError in maestro-cli. */ class DeviceError(override val message: String, override val cause: Throwable? = null) : RuntimeException(message, cause) ================================================ FILE: maestro-client/src/main/java/maestro/device/DeviceOrientation.kt ================================================ package maestro.device enum class DeviceOrientation { PORTRAIT, LANDSCAPE_LEFT, LANDSCAPE_RIGHT, UPSIDE_DOWN; // Return the camelCase representation of the enum name, for example "landscapeLeft" val camelCaseName: String get() = name.split("_") .mapIndexed { index, part -> if (index == 0) part.lowercase() else part.lowercase().capitalize() } .joinToString("") companion object { // Support lookup of enum value by name, ignoring underscores and case. This allow inputs like // "LANDSCAPE_LEFT" or "landscapeLeft" to both be matched to the LANDSCAPE_LEFT enum value. fun getByName(name: String): DeviceOrientation? { return values().find { comparableName(it.name) == comparableName(name) } } private fun comparableName(name: String): String { return name.lowercase().replace("_", "") } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/DeviceService.kt ================================================ package maestro.device import dadb.Dadb import dadb.adbserver.AdbServer import maestro.device.util.AndroidEnvUtils import maestro.device.util.AvdDevice import maestro.device.util.PrintUtils import maestro.drivers.AndroidDriver import maestro.drivers.CdpWebDriver import maestro.utils.MaestroTimer import maestro.utils.TempFileHandler import okio.buffer import okio.source import org.slf4j.LoggerFactory import util.DeviceCtlResponse import util.LocalIOSDevice import util.LocalSimulatorUtils import util.SimctlList import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException data class AvdInfo(val name: String, val model: String, val os: String) object DeviceService { private val logger = LoggerFactory.getLogger(DeviceService::class.java) private val tempFileHandler = TempFileHandler() private val localSimulatorUtils = LocalSimulatorUtils(tempFileHandler) fun startDevice( device: Device.AvailableForLaunch, driverHostPort: Int?, connectedDevices: Set = setOf() ): Device.Connected { when (device.deviceSpec.platform) { Platform.IOS -> { PrintUtils.message("Launching Simulator...") try { localSimulatorUtils.bootSimulator(device.modelId) PrintUtils.message("Setting the device locale to ${device.deviceSpec.locale.code}...") localSimulatorUtils.setDeviceLanguage(device.modelId, device.deviceSpec.locale.languageCode) localSimulatorUtils.setDeviceLocale(device.modelId, device.deviceSpec.locale.code) localSimulatorUtils.reboot(device.modelId) localSimulatorUtils.launchSimulator(device.modelId) localSimulatorUtils.awaitLaunch(device.modelId) } catch (e: LocalSimulatorUtils.SimctlError) { logger.error("Failed to launch simulator", e) throw DeviceError(e.message) } return Device.Connected( instanceId = device.modelId, description = device.description, platform = device.platform, deviceType = device.deviceType, deviceSpec = device.deviceSpec, ) } Platform.ANDROID -> { PrintUtils.message("Launching Emulator...") val emulatorBinary = requireEmulatorBinary() ProcessBuilder( emulatorBinary.absolutePath, "-avd", device.modelId, "-netdelay", "none", "-netspeed", "full" ).start().waitFor(10, TimeUnit.SECONDS) var lastException: Exception? = null val dadb = MaestroTimer.withTimeout(60000) { try { Dadb.list().lastOrNull { dadb -> !connectedDevices.contains(dadb.toString()) } } catch (ignored: Exception) { Thread.sleep(100) lastException = ignored null } } ?: throw DeviceError("Unable to start device: ${device.modelId}", lastException) PrintUtils.message("Waiting for emulator ( ${device.modelId} ) to boot...") while (!bootComplete(dadb)) { Thread.sleep(1000) } PrintUtils.message("Setting the device locale to ${device.deviceSpec.locale.code}...") val driver = AndroidDriver(dadb, driverHostPort) driver.installMaestroDriverApp() val result = driver.setDeviceLocale( country = device.deviceSpec.locale.countryCode, language = device.deviceSpec.locale.languageCode, ) when (result) { SET_LOCALE_RESULT_SUCCESS -> PrintUtils.message("[Done] Setting the device locale to ${device.deviceSpec.locale.code}...") SET_LOCALE_RESULT_LOCALE_NOT_VALID -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, the locale is not valid for a chosen device") SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, exception during updating configuration occurred") else -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, unknown exception happened") } driver.uninstallMaestroDriverApp() return Device.Connected( instanceId = dadb.toString(), description = device.description, platform = device.platform, deviceType = device.deviceType, deviceSpec = device.deviceSpec, ) } Platform.WEB -> { PrintUtils.message("Launching Web...") CdpWebDriver(isStudio = false, isHeadless = false, screenSize = null).open() return Device.Connected( instanceId = "chromium", description = "Chromium Web Browser", platform = device.platform, deviceType = device.deviceType, deviceSpec = device.deviceSpec, ) } } } fun listConnectedDevices( includeWeb: Boolean = false, host: String? = null, port: Int? = null, ): List { return listDevices(includeWeb = includeWeb, host, port) .filterIsInstance() } fun List.withPlatform(platform: Platform?) = filter { platform == null || it.platform == platform } fun listAvailableForLaunchDevices(includeWeb: Boolean = false): List { return listDevices(includeWeb = includeWeb) .filterIsInstance() } fun listDevices(includeWeb: Boolean, host: String? = null, port: Int? = null): List { return listAndroidDevices(host, port) + listIOSDevices() + if (includeWeb) { listWebDevices() } else { listOf() } } fun listWebDevices(): List { return listOf( Device.Connected( platform = Platform.WEB, description = "Chromium Web Browser", instanceId = "chromium", deviceType = Device.DeviceType.BROWSER, deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web()) ), Device.AvailableForLaunch( modelId = "chromium", description = "Chromium Web Browser", platform = Platform.WEB, deviceType = Device.DeviceType.BROWSER, deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web()) ) ) } fun listAndroidDevices(host: String? = null, port: Int? = null): List { val host = host ?: "localhost" if (port != null) { val dadb = Dadb.create(host, port) return listOf( Device.Connected( instanceId = dadb.toString(), description = dadb.toString(), platform = Platform.ANDROID, deviceType = Device.DeviceType.EMULATOR, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Android() ) ) ) } // Fetch AVD info once (model + os) to avoid repeated avdmanager calls val avdInfoList = fetchAndroidAvdInfo() val connected = runCatching { Dadb.list(host = host).map { dadb -> val avdName = runCatching { dadb.shell("getprop ro.kernel.qemu").output.trim().let { qemuProp -> if (qemuProp == "1") { val avdNameResult = ProcessBuilder("adb", "-s", dadb.toString(), "emu", "avd", "name") .redirectErrorStream(true) .start() .apply { waitFor(5, TimeUnit.SECONDS) } .inputStream.bufferedReader().readLine()?.trim() ?: "" if (avdNameResult.isNotBlank() && !avdNameResult.contains("unknown AVD")) { avdNameResult } else null } else null } }.getOrNull() val instanceId = dadb.toString() val deviceType = when { instanceId.startsWith("emulator") -> Device.DeviceType.EMULATOR else -> Device.DeviceType.REAL } val avdInfo = avdInfoList.find { it.name == avdName } ?: AvdInfo(name = avdName ?: "", model = "", os = "") Device.Connected( instanceId = instanceId, description = avdName ?: dadb.toString(), platform = Platform.ANDROID, deviceType = deviceType, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Android() ), ) } }.getOrNull() ?: emptyList() // Note that there is a possibility that AVD is actually already connected and is present in // connectedDevices. val avds = try { val emulatorBinary = requireEmulatorBinary() ProcessBuilder(emulatorBinary.absolutePath, "-list-avds") .start() .inputStream .bufferedReader() .useLines { lines -> lines .map { avdName -> val avdInfo = avdInfoList.find { it.name == avdName } ?: AvdInfo(name = avdName, model = "", os = "") Device.AvailableForLaunch( modelId = avdName, description = avdName, platform = Platform.ANDROID, deviceType = Device.DeviceType.EMULATOR, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Android(avdInfo.model, avdInfo.os) ) ) } .toList() } } catch (ignored: Exception) { emptyList() } return connected + avds } /** * Runs `avdmanager list avd` and returns a list of AvdInfo * - name * - model: the canonical device ID from avdmanager, e.g. "pixel_6" * - os: "android-XX" derived from the API level, e.g. "android-34" * * Falls back to config.ini for the OS if avdmanager output lacks it. * Returns empty string on any failure */ private fun fetchAndroidAvdInfo(): List { return try { val avd = requireAvdManagerBinary() val output = ProcessBuilder(avd.absolutePath, "list", "avd") .redirectErrorStream(true) .start() .apply { waitFor(30, TimeUnit.SECONDS) } .inputStream.bufferedReader().readText() parseAvdInfo(output, AndroidEnvUtils.androidAvdHome) } catch (e: Exception) { emptyList() } } internal fun parseAvdInfo(output: String, avdHome: File): List { val result = mutableListOf() var currentName: String? = null var currentModel: String? = null var currentOs: String? = null for (line in output.lines()) { val trimmed = line.trim() when { trimmed.startsWith("Name:") -> { // Save previous block if (currentName != null) { result += AvdInfo(name = currentName, model = currentModel ?: "", os = currentOs ?: "") } currentName = trimmed.removePrefix("Name:").trim() currentModel = null currentOs = null } trimmed.startsWith("Device:") -> { // "pixel_6 (Google Pixel 6)" → "pixel_6" currentModel = trimmed.removePrefix("Device:").trim().substringBefore(" ") } currentOs == null && currentName != null -> { // Read OS from config.ini once we have the AVD name val configFile = File(avdHome, "$currentName.avd/config.ini") if (configFile.exists()) { val sysdir = configFile.readLines() .firstOrNull { it.startsWith("image.sysdir.1=") } ?.substringAfter("=") currentOs = sysdir?.split("/")?.firstOrNull { it.startsWith("android-") } } } } } // Save last block if (currentName != null) { result += AvdInfo(name = currentName, model = currentModel ?: "", os = currentOs ?: "") } return result } fun listIOSDevices(): List { val simctlList = try { localSimulatorUtils.list() } catch (ignored: Exception) { return emptyList() } val runtimeNameByIdentifier = simctlList .runtimes .associate { it.identifier to it.name } return simctlList .devices .flatMap { runtime -> runtime.value .filter { it.isAvailable } .map { device(runtimeNameByIdentifier, runtime, it) } } + listIOSConnectedDevices() } fun listIOSConnectedDevices(): List { val connectedIphoneList = LocalIOSDevice().listDeviceViaDeviceCtl() return connectedIphoneList.mapNotNull { device -> val udid = device.hardwareProperties?.udid if (device.connectionProperties.tunnelState != DeviceCtlResponse.ConnectionProperties.CONNECTED || udid == null) { return@mapNotNull null } val description = listOfNotNull( device.deviceProperties?.name, device.deviceProperties?.osVersionNumber, device.identifier ).joinToString(" - ") Device.Connected( instanceId = udid, description = description, platform = Platform.IOS, deviceType = Device.DeviceType.REAL, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Ios() ) ) } } private fun device( runtimeNameByIdentifier: Map, runtime: Map.Entry>, device: SimctlList.Device, ): Device { val runtimeName = runtimeNameByIdentifier[runtime.key] ?: "Unknown runtime" val description = "${device.name} - $runtimeName - ${device.udid}" // "com.apple.CoreSimulator.SimDeviceType.iPhone-XS" → "iPhone-XS" val model = device.deviceTypeIdentifier?.substringAfterLast(".") ?: "" // "com.apple.CoreSimulator.SimRuntime.iOS-17-5" → "iOS-17-5" val os = runtime.key.substringAfterLast(".") return if (device.state == "Booted") { Device.Connected( instanceId = device.udid, description = description, platform = Platform.IOS, deviceType = Device.DeviceType.SIMULATOR, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Ios(model, os) ) ) } else { Device.AvailableForLaunch( modelId = device.udid, description = description, platform = Platform.IOS, deviceType = Device.DeviceType.SIMULATOR, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Ios(model, os) ) ) } } /** * @return true if ios simulator or android emulator is currently connected */ fun isDeviceConnected(deviceName: String, platform: Platform): Device.Connected? { return when (platform) { Platform.IOS -> listIOSDevices() .filterIsInstance() .find { it.description.contains(deviceName, ignoreCase = true) } else -> runCatching { (Dadb.list() + AdbServer.listDadbs(adbServerPort = 5038)) .mapNotNull { dadb -> runCatching { dadb.shell("getprop ro.kernel.qemu.avd_name").output }.getOrNull() } .map { output -> Device.Connected( instanceId = output, description = output, platform = Platform.ANDROID, deviceType = Device.DeviceType.EMULATOR, deviceSpec = DeviceSpec.fromRequest( DeviceSpecRequest.Android() ) ) } .find { connectedDevice -> connectedDevice.description.contains(deviceName, ignoreCase = true) } }.getOrNull() } } /** * @return true if ios simulator or android emulator is available to launch */ fun isDeviceAvailableToLaunch(deviceName: String, platform: Platform): Device.AvailableForLaunch? { return if (platform == Platform.IOS) { listIOSDevices() .filterIsInstance() .find { it.description.contains(deviceName, ignoreCase = true) } } else { listAndroidDevices() .filterIsInstance() .find { it.description.contains(deviceName, ignoreCase = true) } } } /** * Creates an iOS simulator * * @param deviceName Any name * @param device Simulator type as specified by Apple i.e. iPhone-11 * @param os OS runtime name as specified by Apple i.e. iOS-16-2 */ fun createIosDevice(deviceName: String, device: String, os: String): UUID { val command = listOf( "xcrun", "simctl", "create", deviceName, "com.apple.CoreSimulator.SimDeviceType.$device", "com.apple.CoreSimulator.SimRuntime.$os" ) val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(5, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() != 0) { val processOutput = process.errorStream .source() .buffer() .readUtf8() throw IllegalStateException(processOutput) } else { val output = String(process.inputStream.readBytes()).trim() return try { UUID.fromString(output) } catch (ignore: IllegalArgumentException) { throw IllegalStateException("Unable to create device. No UUID was generated") } } } /** * Creates an Android emulator * * @param deviceName Any device name * @param device Device type as specified by the Android SDK i.e. "pixel_6" * @param systemImage Full system package i.e "system-images;android-28;google_apis;x86_64" * @param tag google apis or playstore tag i.e. google_apis or google_apis_playstore * @param abi x86_64, x86, arm64 etc.. */ fun createAndroidDevice( deviceName: String, device: String, systemImage: String, tag: String, abi: String, force: Boolean = false, ): String { val avd = requireAvdManagerBinary() val name = deviceName val command = mutableListOf( avd.absolutePath, "create", "avd", "--name", name, "--package", systemImage, "--tag", tag, "--abi", abi, "--device", device, ) if (force) command.add("--force") val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(5, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() != 0) { val processOutput = process.errorStream .source() .buffer() .readUtf8() throw IllegalStateException("Failed to start android emulator: $processOutput") } return name } fun getAvailablePixelDevices(): List { val avd = requireAvdManagerBinary() val command = mutableListOf( avd.absolutePath, "list", "device" ) val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(1, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() != 0) { val processOutput = process.inputStream.source().buffer().readUtf8() + "\n" + process.errorStream.source().buffer().readUtf8() throw IllegalStateException("Failed to list avd devices emulator: $processOutput") } return runCatching { AndroidEnvUtils.parsePixelDevices(String(process.inputStream.readBytes()).trim()) }.getOrNull() ?: emptyList() } /** * @return true is Android system image is already installed */ fun isAndroidSystemImageInstalled(image: String): Boolean { val command = listOf( requireSdkManagerBinary().absolutePath, "--list_installed" ) try { val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(1, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() == 0) { val output = String(process.inputStream.readBytes()).trim() return output.contains(image) } } catch (e: Exception) { logger.error("Unable to detect if SDK package is installed", e) } return false } /** * Uses the Android SDK manager to install android image */ fun installAndroidSystemImage(image: String): Boolean { val command = listOf( requireSdkManagerBinary().absolutePath, image ) try { val process = ProcessBuilder(*command.toTypedArray()) .inheritIO() .start() if (!process.waitFor(120, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() == 0) { val output = String(process.inputStream.readBytes()).trim() return output.contains(image) } } catch (e: Exception) { logger.error("Unable to install if SDK package is installed", e) } return false } fun getAndroidSystemImageInstallCommand(pkg: String): String { return listOf( requireSdkManagerBinary().absolutePath, "\"$pkg\"" ).joinToString(separator = " ") } fun deleteIosDevice(uuid: String): Boolean { val command = listOf( "xcrun", "simctl", "delete", uuid ) val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(1, TimeUnit.MINUTES)) { throw TimeoutException() } return process.exitValue() == 0 } fun killAndroidDevice(deviceId: String): Boolean { val command = listOf("adb", "-s", deviceId, "emu", "kill") try { val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(1, TimeUnit.MINUTES)) { throw TimeoutException("Android kill command timed out") } val success = process.exitValue() == 0 if (success) { logger.info("Killed Android device: $deviceId") } else { logger.error("Failed to kill Android device: $deviceId") } return success } catch (e: Exception) { logger.error("Error killing Android device: $deviceId", e) return false } } fun killIOSDevice(deviceId: String): Boolean { val command = listOf("xcrun", "simctl", "shutdown", deviceId) try { val process = ProcessBuilder(*command.toTypedArray()).start() if (!process.waitFor(1, TimeUnit.MINUTES)) { throw TimeoutException("iOS kill command timed out") } val success = process.exitValue() == 0 if (success) { logger.info("Killed iOS device: $deviceId") } else { logger.error("Failed to kill iOS device: $deviceId") } return success } catch (e: Exception) { logger.error("Error killing iOS device: $deviceId", e) return false } } private fun bootComplete(dadb: Dadb): Boolean { return try { val booted = dadb.shell("getprop sys.boot_completed").output.trim() == "1" val settingsAvailable = dadb.shell("settings list global").exitCode == 0 val packageManagerAvailable = dadb.shell("pm get-max-users").exitCode == 0 return settingsAvailable && packageManagerAvailable && booted } catch (e: IllegalStateException) { false } } private fun requireEmulatorBinary(): File = AndroidEnvUtils.requireEmulatorBinary() private fun requireAvdManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools("avdmanager") private fun requireSdkManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools("sdkmanager") private const val SET_LOCALE_RESULT_SUCCESS = 0 private const val SET_LOCALE_RESULT_LOCALE_NOT_VALID = 1 private const val SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED = 2 } ================================================ FILE: maestro-client/src/main/java/maestro/device/DeviceSpec.kt ================================================ package maestro.device import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import maestro.device.locale.DeviceLocale enum class CPU_ARCHITECTURE(val value: String) { X86_64("x86_64"), ARM64("arm64-v8a"), UNKNOWN("unknown"); companion object { fun fromString(p: String?): Platform? { return Platform.entries.firstOrNull { it.description.equals(p, ignoreCase = true) } } } } /** * Returned Sealed class that has all non-nullable values */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "platform") @JsonSubTypes( JsonSubTypes.Type(DeviceSpec.Android::class, name = "ANDROID"), JsonSubTypes.Type(DeviceSpec.Ios::class, name = "IOS"), JsonSubTypes.Type(DeviceSpec.Web::class, name = "WEB"), ) sealed class DeviceSpec { abstract val platform: Platform abstract val model: String abstract val os: String abstract val osVersion: Int abstract val deviceName: String abstract val locale: DeviceLocale data class Android( override val model: String, override val os: String, override val locale: DeviceLocale, val orientation: DeviceOrientation, val disableAnimations: Boolean, val cpuArchitecture: CPU_ARCHITECTURE, ) : DeviceSpec() { override val platform = Platform.ANDROID override val osVersion: Int = os.removePrefix("android-").toIntOrNull() ?: 0 override val deviceName = "Maestro_ANDROID_${model}_${os}" val tag = "google_apis" val emulatorImage = "system-images;$os;$tag;${cpuArchitecture.value}" } data class Ios( override val model: String, override val os: String, override val locale: DeviceLocale, val orientation: DeviceOrientation, val disableAnimations: Boolean, val snapshotKeyHonorModalViews: Boolean, ) : DeviceSpec() { override val platform = Platform.IOS override val osVersion: Int = os.removePrefix("iOS-").substringBefore("-").toIntOrNull() ?: 0 override val deviceName = "Maestro_IOS_${model}_${osVersion}" } data class Web( override val model: String, override val os: String, override val locale: DeviceLocale ) : DeviceSpec() { override val platform = Platform.WEB override val osVersion: Int = 0 override val deviceName = "Maestro_WEB_${model}_${osVersion}" } companion object { /** * Creates a fully resolved DeviceSpec from a DeviceRequest, filling in platform-aware defaults. * The returned spec is not environment-validated. * Environment-specific validation for model & os can happen via SupportedDevices.validate(). */ fun fromRequest(request: DeviceSpecRequest): DeviceSpec { return when (request) { is DeviceSpecRequest.Android -> Android( model = request.model ?: "pixel_6", os = request.os ?: "android-33", locale = DeviceLocale.fromString(request.locale ?: "en_US", Platform.ANDROID), orientation = request.orientation ?: DeviceOrientation.PORTRAIT, disableAnimations = request.disableAnimations ?: true, cpuArchitecture = request.cpuArchitecture ?: CPU_ARCHITECTURE.ARM64, ) is DeviceSpecRequest.Ios -> Ios( model = request.model ?: "iPhone-11", os = request.os ?: "iOS-17-5", locale = DeviceLocale.fromString(request.locale ?: "en_US", Platform.IOS), orientation = request.orientation ?: DeviceOrientation.PORTRAIT, disableAnimations = request.disableAnimations ?: true, snapshotKeyHonorModalViews = request.snapshotKeyHonorModalViews ?: true, ) is DeviceSpecRequest.Web -> Web( model = request.model ?: "chromium", os = request.os ?: "default", locale = DeviceLocale.fromString(request.locale ?: "en_US", Platform.WEB), ) } } } } /** * Request for setting up device config */ sealed class DeviceSpecRequest { abstract val platform: Platform data class Android( val model: String? = null, val os: String? = null, val locale: String? = null, val orientation: DeviceOrientation? = null, val disableAnimations: Boolean? = null, val cpuArchitecture: CPU_ARCHITECTURE? = null, ) : DeviceSpecRequest() { override val platform = Platform.ANDROID } data class Ios( val model: String? = null, val os: String? = null, val locale: String? = null, val orientation: DeviceOrientation? = null, val disableAnimations: Boolean? = null, val snapshotKeyHonorModalViews: Boolean? = null, ) : DeviceSpecRequest() { override val platform = Platform.IOS } data class Web( val model: String? = null, val os: String? = null, val locale: String? = null, ) : DeviceSpecRequest() { override val platform = Platform.WEB } } ================================================ FILE: maestro-client/src/main/java/maestro/device/Platform.kt ================================================ package maestro.device enum class Platform(val description: String) { ANDROID("Android"), IOS("iOS"), WEB("Web"); companion object { fun fromString(p: String): Platform { return entries.firstOrNull { it.description.equals(p, ignoreCase = true) } ?: throw IllegalArgumentException( "Unknown platform: '$p'. Must be one of: ${entries.joinToString { it.description }}" ) } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/locale/AndroidLocale.kt ================================================ package maestro.device.locale import maestro.device.Platform import java.util.Locale /** * Type-safe enum representing Android-supported languages. * These are a subset of languages available in Java's Locale API. */ enum class AndroidLanguage(val code: String) { ARABIC("ar"), BULGARIAN("bg"), CATALAN("ca"), CHINESE("zh"), CROATIAN("hr"), CZECH("cs"), DANISH("da"), DUTCH("nl"), ENGLISH("en"), FINNISH("fi"), FRENCH("fr"), GERMAN("de"), GREEK("el"), HEBREW("he"), HINDI("hi"), HUNGARIAN("hu"), INDONESIAN("id"), ITALIAN("it"), JAPANESE("ja"), KOREAN("ko"), LATVIAN("lv"), LITHUANIAN("lt"), NORWEGIAN_BOKMOL("nb"), POLISH("pl"), PORTUGUESE("pt"), ROMANIAN("ro"), RUSSIAN("ru"), SERBIAN("sr"), SLOVAK("sk"), SLOVENIAN("sl"), SPANISH("es"), SWEDISH("sv"), TAGALOG("tl"), THAI("th"), TURKISH("tr"), UKRAINIAN("uk"), VIETNAMESE("vi"); // Gets the display name for this language using Java's Locale API. // Falls back to the enum name if Locale doesn't recognize the code. val displayName: String get() = try { Locale(code).getDisplayLanguage(Locale.US).takeIf { it.isNotBlank() } ?: code.uppercase() } catch (e: Exception) { code.uppercase() } companion object { init { // Validate that all enum codes are valid ISO-639-1 language codes val validISOCodes = Locale.getISOLanguages().toSet() entries.forEach { language -> require(language.code in validISOCodes) { "Language code '${language.code}' in AndroidLanguage enum is not a valid ISO-639-1 code" } } } // Gets all language codes as a set. val allCodes: Set get() = entries.map { it.code }.toSet() // Finds a language by its code. fun fromCode(code: String): AndroidLanguage? { return entries.find { it.code == code } } } } /** * Type-safe enum representing Android-supported countries. * These are a subset of countries available in Java's Locale API. */ enum class AndroidCountry(val code: String) { AUSTRALIA("AU"), AUSTRIA("AT"), BELGIUM("BE"), BRAZIL("BR"), BRITAIN("GB"), BULGARIA("BG"), CANADA("CA"), CROATIA("HR"), CZECH_REPUBLIC("CZ"), DENMARK("DK"), EGYPT("EG"), FINLAND("FI"), FRANCE("FR"), GERMANY("DE"), GREECE("GR"), HONG_KONG("HK"), HUNGARY("HU"), INDIA("IN"), INDONESIA("ID"), IRELAND("IE"), ISRAEL("IL"), ITALY("IT"), JAPAN("JP"), KOREA("KR"), LATVIA("LV"), LIECHTENSTEIN("LI"), LITHUANIA("LT"), MEXICO("MX"), NETHERLANDS("NL"), NEW_ZEALAND("NZ"), NORWAY("NO"), PHILIPPINES("PH"), POLAND("PL"), PORTUGAL("PT"), PRC("CN"), ROMANIA("RO"), RUSSIA("RU"), SERBIA("RS"), SINGAPORE("SG"), SLOVAKIA("SK"), SLOVENIA("SI"), SPAIN("ES"), SWEDEN("SE"), SWITZERLAND("CH"), TAIWAN("TW"), THAILAND("TH"), TURKEY("TR"), UKRAINE("UA"), USA("US"), VIETNAM("VN"), ZIMBABWE("ZA"); // Gets the display name for this country using Java's Locale API. // Falls back to the enum name if Locale doesn't recognize the code. val displayName: String get() = try { Locale("", code).getDisplayCountry(Locale.US).takeIf { it.isNotBlank() } ?: code } catch (e: Exception) { code } companion object { init { // Validate that all enum codes are valid ISO-3166-1 country codes val validISOCodes = Locale.getISOCountries().toSet() entries.forEach { country -> require(country.code in validISOCodes) { "Country code '${country.code}' in AndroidCountry enum is not a valid ISO-3166-1 code" } } } // Gets all country codes as a set. val allCodes: Set get() = entries.map { it.code }.toSet() // Finds a country by its code. fun fromCode(code: String): AndroidCountry? { return entries.find { it.code == code } } } } /** * Android device locale - a dynamic combination of language and country. * Android supports all combinations of supported languages and countries. */ data class AndroidLocale( val language: AndroidLanguage, val country: AndroidCountry ) : DeviceLocale { override val code: String get() = "${language.code}_${country.code}" override val displayName: String get() = try { Locale(language.code, country.code).getDisplayName(Locale.US) } catch (e: Exception) { "${language.displayName} (${country.displayName})" } override val languageCode: String get() = language.code override val countryCode: String get() = country.code override val platform: Platform = Platform.ANDROID companion object { /** * Cached set of available Java Locale combinations (language_country format). * Computed once and reused for efficient O(1) lookups. */ private val availableJavaLocaleCombinations: Set = Locale.getAvailableLocales() .map { "${it.language}_${it.country}" } .toSet() /** * Creates an AndroidLocale from a locale string (e.g., "en_US"). * Validates that both language and country codes are supported. * @throws LocaleValidationException if the locale string is invalid or unsupported */ fun fromString(localeString: String): AndroidLocale { val parts = localeString.split("_", "-") if (parts.size != 2) { throw LocaleValidationException( "Failed to validate device locale, $localeString is not a valid format. Expected format: language_country, e.g., en_US." ) } val languageCode = parts[0] val countryCode = parts[1] val language = AndroidLanguage.fromCode(languageCode) ?: throw LocaleValidationException( "Failed to validate Android device language, $languageCode is not a supported Android language. Here is a full list of supported languageCode: \n\n ${AndroidLanguage.allCodes.joinToString(", ")}" ) val country = AndroidCountry.fromCode(countryCode) ?: throw LocaleValidationException( "Failed to validate Android device country, $countryCode is not a supported Android country. Here is a full list of supported countryCode: \n\n ${AndroidCountry.allCodes.joinToString(", ")}" ) // Validate that the language-country combination exists in Java Locale if ("${languageCode}_${countryCode}" !in availableJavaLocaleCombinations) { throw LocaleValidationException( "Failed to validate Android device locale combination, $localeString is not a valid locale combination. Here is a full list of supported locales: \n\n ${allCodes.joinToString(", ")}" ) } return AndroidLocale(language, country) } /** * Validates if a locale string represents a valid Android locale combination. */ fun isValid(localeString: String): Boolean { return try { fromString(localeString) true } catch (e: LocaleValidationException) { false } } /** * Generates all valid Android locale combinations dynamically. * This creates all combinations of supported languages and countries * that are valid Java locale combinations. */ val all: List get() = AndroidLanguage.entries.flatMap { language -> AndroidCountry.entries.mapNotNull { country -> val localeKey = "${language.code}_${country.code}" // Only include combinations that are valid Java locales if (localeKey in availableJavaLocaleCombinations) { AndroidLocale(language, country) } else { null } } } /** * Gets all locale codes as a set. */ val allCodes: Set get() = all.map { it.code }.toSet() /** * Finds a locale code given language and country codes for Android. * @return Locale code if found (e.g., "en_US"), null otherwise */ fun find(languageCode: String, countryCode: String): String? { return if (isValid("${languageCode}_${countryCode}")) { "${languageCode}_${countryCode}" } else { null } } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/locale/DeviceLocale.kt ================================================ package maestro.device.locale import maestro.device.Platform import java.util.Locale /** * Sealed interface for device locales to provide common behavior when needed. * This allows platform-agnostic code to work with locales while keeping * Android, iOS, and Web types separate for platform-specific code. */ sealed interface DeviceLocale { // Gets the locale code representation (e.g., "en_US", "zh-Hans"). val code: String // Gets the display name for this locale. val displayName: String // Gets the language code for this locale (e.g., "en", "fr", "zh"). val languageCode: String // Gets the country code for this locale (e.g., "US", "FR", "CN"). val countryCode: String // Gets the platform this locale is for. val platform: Platform companion object { /** * Creates a DeviceLocale from a locale string and platform. * This is useful for platform-agnostic code that needs to work with locales. * * @throws LocaleValidationException if the locale string is invalid or unsupported */ fun fromString(localeString: String, platform: Platform): DeviceLocale { return when (platform) { Platform.ANDROID -> AndroidLocale.fromString(localeString) Platform.IOS -> IosLocale.fromString(localeString) Platform.WEB -> WebLocale.fromString(localeString) } } /** * Validates if a locale string is valid for the given platform. */ fun isValid(localeString: String, platform: Platform): Boolean { return try { fromString(localeString, platform) true } catch (e: LocaleValidationException) { false } } /** * Gets all supported locales for a platform. */ fun all(platform: Platform): List { return when (platform) { Platform.ANDROID -> AndroidLocale.all Platform.IOS -> IosLocale.entries Platform.WEB -> WebLocale.entries } } /** * Gets all supported locale codes for a platform. */ fun allCodes(platform: Platform): Set { return when (platform) { Platform.ANDROID -> AndroidLocale.allCodes Platform.IOS -> IosLocale.allCodes Platform.WEB -> WebLocale.allCodes } } /** * Finds a locale code given language and country codes for the specified platform. * @return Locale code if found (e.g., "en_US" or "en-US"), null otherwise */ fun find(languageCode: String, countryCode: String, platform: Platform): String? { return when (platform) { Platform.ANDROID -> AndroidLocale.find(languageCode, countryCode) Platform.IOS -> IosLocale.find(languageCode, countryCode) Platform.WEB -> WebLocale.find(languageCode, countryCode) } } // Gets the default locale value fun getDefault(platform: Platform): DeviceLocale { return when(platform) { Platform.ANDROID -> AndroidLocale.fromString("en_US") Platform.IOS -> IosLocale.fromString("en_US") Platform.WEB -> WebLocale.fromString("en_US") } } /** * Generates a display name for a locale code using Java's Locale API. * Parses locale codes in both underscore (en_US) and hyphen (en-US) formats. * Falls back to returning the original code if parsing fails. * * @param localeCode The locale code to generate a display name for * @return A human-readable display name (e.g., "en_US" -> "English (United States)"), or the original code if parsing fails */ internal fun getDisplayNameFromCode(localeCode: String): String { try { val parts = localeCode.split("_", "-") if (parts.size == 2) { val javaLocale = Locale(parts[0], parts[1]) val displayName = javaLocale.getDisplayName(Locale.US) if (displayName.isNotBlank()) { return displayName } } } catch (e: Exception) { // Fall through to return locale string } return localeCode } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/locale/IosLocale.kt ================================================ package maestro.device.locale import maestro.device.Platform /** * iOS device locale - fixed enum of supported locale combinations. */ enum class IosLocale(override val code: String) : DeviceLocale { EN_AU("en_AU"), NL_BE("nl_BE"), FR_BE("fr_BE"), MS_BN("ms_BN"), EN_CA("en_CA"), FR_CA("fr_CA"), CS_CZ("cs_CZ"), FI_FI("fi_FI"), DE_DE("de_DE"), EL_GR("el_GR"), HU_HU("hu_HU"), HI_IN("hi_IN"), ID_ID("id_ID"), HE_IL("he_IL"), IT_IT("it_IT"), JA_JP("ja_JP"), MS_MY("ms_MY"), NL_NL("nl_NL"), EN_NZ("en_NZ"), NB_NO("nb_NO"), TL_PH("tl_PH"), PL_PL("pl_PL"), ZH_CN("zh_CN"), RO_RO("ro_RO"), RU_RU("ru_RU"), EN_SG("en_SG"), SK_SK("sk_SK"), KO_KR("ko_KR"), SV_SE("sv_SE"), ZH_TW("zh_TW"), TH_TH("th_TH"), TR_TR("tr_TR"), EN_GB("en_GB"), UK_UA("uk_UA"), ES_US("es_US"), EN_US("en_US"), VI_VN("vi_VN"), ES_ES("es_ES"), FR_FR("fr_FR"), // Hyphenated locales (language-country format) PT_BR("pt-BR"), ZH_HK("zh-HK"), EN_IN("en-IN"), EN_IE("en-IE"), ES_MX("es-MX"), EN_ZA("en-ZA"), // iOS-specific locale formats (not standard ISO locale combinations) ZH_HANS("zh-Hans"), ZH_HANT("zh-Hant"), ES_419("es-419"); override val displayName: String get() { return when (this) { ZH_HANS -> "Chinese (Simplified)" ZH_HANT -> "Chinese (Traditional)" ES_419 -> "Spanish (Latin America)" else -> DeviceLocale.getDisplayNameFromCode(code) } } override val languageCode: String get() { val parts = code.split("_", "-") return parts[0] } override val countryCode: String get() = code.split("_", "-")[1] override val platform: Platform = Platform.IOS companion object { /** * Gets all locale codes as a set. */ val allCodes: Set get() = entries.map { it.code }.toSet() /** * Finds a locale by its string representation. * Accepts both underscore and hyphen formats. * * @throws LocaleValidationException if not found */ fun fromString(localeString: String): IosLocale { return entries.find { it.code == localeString || it.code.replace("_", "-") == localeString || it.code.replace("-", "_") == localeString } ?: throw LocaleValidationException("Failed to validate iOS device locale. Here is a full list of supported locales: \n\n ${allCodes.joinToString(", ")}") } /** * Validates if a locale string is valid for iOS. */ fun isValid(localeString: String): Boolean { return entries.any { it.code == localeString || it.code.replace("_", "-") == localeString || it.code.replace("-", "_") == localeString } } /** * Finds a locale code given language and country codes. * Tries both underscore and hyphen formats. * @return Locale code if found (e.g., "en_US" or "en-US"), null otherwise */ fun find(languageCode: String, countryCode: String): String? { // Try underscore format first val underscoreFormat = "${languageCode}_$countryCode" if (isValid(underscoreFormat)) { return underscoreFormat } // Try hyphen format val hyphenFormat = "$languageCode-$countryCode" if (isValid(hyphenFormat)) { return hyphenFormat } return null } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/locale/LocaleValidationException.kt ================================================ package maestro.device.locale class LocaleValidationException(message: String): Exception(message) ================================================ FILE: maestro-client/src/main/java/maestro/device/locale/WebLocale.kt ================================================ package maestro.device.locale import maestro.device.Platform /** * Web device locale - fixed enum of supported locale combinations. */ enum class WebLocale(override val code: String) : DeviceLocale { EN_US("en_US"); override val displayName: String get() = DeviceLocale.getDisplayNameFromCode(code) override val languageCode: String get() { val parts = code.split("_", "-") return parts[0] } override val countryCode: String get() = code.split("_", "-")[1] override val platform: Platform = Platform.WEB companion object { /** * Gets all locale codes as a set. */ val allCodes: Set get() = entries.map { it.code }.toSet() /** * Finds a locale by its string representation. * @throws LocaleValidationException if not found */ fun fromString(localeString: String): WebLocale { return entries.find { it.code == localeString } ?: throw LocaleValidationException("Failed to validate web browser locale: $localeString. Here is a full list of supported locales: \n\n ${allCodes.joinToString(", ")}") } /** * Validates if a locale string is valid for Web. */ fun isValid(localeString: String): Boolean { return entries.any { it.code == localeString } } /** * Finds a locale code given language and country codes. */ fun find(languageCode: String, countryCode: String): String? { val underscoreFormat = "${languageCode}_$countryCode" if (isValid(underscoreFormat)) { return underscoreFormat } return null } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/serialization/DeviceLocaleSerializer.kt ================================================ package maestro.device.serialization import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.ser.std.StdSerializer import maestro.device.Platform import maestro.device.locale.DeviceLocale class DeviceLocaleSerializer : StdSerializer(DeviceLocale::class.java) { override fun serialize(value: DeviceLocale, gen: JsonGenerator, provider: SerializerProvider) { gen.writeStartObject() gen.writeStringField("code", value.code) gen.writeStringField("platform", value.platform.name) gen.writeEndObject() } } class DeviceLocaleDeserializer : StdDeserializer(DeviceLocale::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): DeviceLocale { val node = p.codec.readTree(p) val code = node.get("code").asText() val platform = Platform.valueOf(node.get("platform").asText()) return DeviceLocale.fromString(code, platform) } } ================================================ FILE: maestro-client/src/main/java/maestro/device/serialization/DeviceSpecModule.kt ================================================ package maestro.device.serialization import com.fasterxml.jackson.databind.module.SimpleModule import maestro.device.locale.DeviceLocale class DeviceSpecModule : SimpleModule("DeviceSpecModule") { init { addSerializer(DeviceLocale::class.java, DeviceLocaleSerializer()) addDeserializer(DeviceLocale::class.java, DeviceLocaleDeserializer()) } } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/AndroidEnvUtils.kt ================================================ package maestro.device.util import maestro.device.DeviceError import okio.buffer import okio.source import java.io.File import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException object AndroidEnvUtils { private val androidHome: String? get() { return System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") ?: System.getenv("ANDROID_SDK_HOME") ?: System.getenv("ANDROID_SDK") ?: System.getenv("ANDROID") } private val androidUserHome: Path get() { if (System.getenv("ANDROID_USER_HOME") != null) { return Paths.get(System.getenv("ANDROID_USER_HOME")) } return Paths.get(System.getProperty("user.home"), ".android") } val androidAvdHome: File get() { System.getenv("ANDROID_AVD_HOME")?.let { return File(it) } return androidUserHome.resolve("avd").toFile() } /** * Returns SDK versions that are used by AVDs present in the system. */ val androidEmulatorSdkVersions: List get() { val iniFiles = androidUserHome.resolve("avd").toFile() .listFiles { file -> file.extension == "ini" } ?.map { it } ?: emptyList() val versions = iniFiles .mapNotNull { iniFile -> iniFile.readLines().firstOrNull { it.startsWith("target=") } } .map { line -> line.split('=') } .filter { lineParts -> lineParts.size == 2 } .map { lineParts -> lineParts[1] } .distinct() .toList() return versions } /** * @return Path to java compatible android cmdline-tools */ fun requireCommandLineTools(tool: String): File { val androidHome = androidHome ?: throw DeviceError("Could not detect Android home environment variable is not set. Ensure that either ANDROID_HOME or ANDROID_SDK_ROOT is set.") val javaVersion = SystemInfo.getJavaVersion() val recommendedToolsVersion = getRecommendedToolsVersion() val tools = File(androidHome, "cmdline-tools") if (!tools.exists()) { throw DeviceError( "Missing required component cmdline-tools. To install:\n" + "1) Open Android Studio SDK manager \n" + "2) Check \"Show package details\" to show all versions\n" + "3) Install Android SDK Command-Line Tools. Recommended version: $recommendedToolsVersion\n" + "* https://developer.android.com/studio/intro/update#sdk-manager" ) } return findCompatibleCommandLineTool(tool) ?: throw DeviceError( "Unable to find compatible cmdline-tools ($tools/) for java version $javaVersion.\n\n" + "Try to install a different cmdline-tools version:\n" + "1) Open Android Studio SDK manager \n" + "2) Check \"Show package details\" to show all versions\n" + "3) Install Android SDK Command-Line Tools. Recommended version: $recommendedToolsVersion\n" + "* https://developer.android.com/studio/intro/update#sdk-manager" ) } private fun getRecommendedToolsVersion(): String { return when (SystemInfo.getJavaVersion()) { 8 -> "8.0" 11 -> "10.0" 17 -> "11.0" else -> "latest" } } private fun findCompatibleCommandLineTool(tool: String): File? { val path = File(androidHome, "cmdline-tools") var thisTool = tool if (EnvUtils.isWindows()){ thisTool = "$tool.bat" } return path.listFiles() ?.filter { it.isDirectory && File(it, "/bin/$thisTool").exists() } ?.filter { isCommandLineToolCompatible(File(it, "bin/$thisTool")) } ?.sortedWith(compareBy { it.name != "latest" } .thenByDescending { it.name.toDoubleOrNull() }) ?.map { File(it, "bin/$thisTool") } ?.firstOrNull() } /** * @return true if tool is compatible with running java version */ private fun isCommandLineToolCompatible(toolPath: File): Boolean { return runCatching { val process = ProcessBuilder(listOf(toolPath.absolutePath, "-h")).start() if (!process.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException() // don't rely on exit code, it's wrong val output = process.errorStream .source() .buffer() .readUtf8() return !output.contains("UnsupportedClassVersionError", ignoreCase = true) }.getOrNull() ?: false } /** * @return parses a string from 'avdmanager list device' and returns the pixel devices */ fun parsePixelDevices(input: String): List { val pattern = "id: (\\d+) or \"(pixel.*?)\"\\n.*?Name: (.*?)\\n".toRegex() return pattern.findAll(input) .map { matchResult -> AvdDevice( matchResult.groupValues[1], matchResult.groupValues[2], matchResult.groupValues[3] ) } .toList() } fun requireEmulatorBinary(): File { val androidHome = androidHome ?: throw DeviceError("Could not detect Android home environment variable is not set. Ensure that either ANDROID_HOME or ANDROID_SDK_ROOT is set.") val firstChoice = File(androidHome, "emulator/emulator") val secondChoice = File(androidHome, "tools/emulator") return firstChoice.takeIf { it.exists() } ?: secondChoice.takeIf { it.exists() } ?: throw DeviceError("Could not find emulator binary at either of the following paths:\n$firstChoice\n$secondChoice") } } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/AvdDevice.kt ================================================ package maestro.device.util data class AvdDevice(val numericId: String, val nameId: String, val name: String) ================================================ FILE: maestro-client/src/main/java/maestro/device/util/CommandLineUtils.kt ================================================ package maestro.device.util import okio.buffer import okio.source import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException object CommandLineUtils { private val isWindows = System.getProperty("os.name").startsWith("Windows") private val nullFile = File(if (isWindows) "NUL" else "/dev/null") private val logger = LoggerFactory.getLogger(CommandLineUtils::class.java) @Suppress("SpreadOperator") fun runCommand(parts: List, waitForCompletion: Boolean = true, outputFile: File? = null, params: Map = emptyMap()): Process { logger.info("Running command line operation: $parts") val processBuilder = if (outputFile != null) { ProcessBuilder(*parts.toTypedArray()) .redirectOutput(outputFile) .redirectError(outputFile) } else { ProcessBuilder(*parts.toTypedArray()) .redirectOutput(nullFile) .redirectError(ProcessBuilder.Redirect.PIPE) } processBuilder.environment().putAll(params) val process = processBuilder.start() if (waitForCompletion) { if (!process.waitFor(5, TimeUnit.MINUTES)) { throw TimeoutException() } if (process.exitValue() != 0) { val processOutput = process.errorStream .source() .buffer() .readUtf8() logger.error("Process failed with exit code ${process.exitValue()}") logger.error("Error output $processOutput") throw IllegalStateException(processOutput) } } return process } } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/EnvUtils.kt ================================================ package maestro.device.util import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException object EnvUtils { val OS_NAME: String = System.getProperty("os.name") val OS_ARCH: String = System.getProperty("os.arch") val OS_VERSION: String = System.getProperty("os.version") /** * @return true, if we're executing from Windows Linux shell (WSL) */ fun isWSL(): Boolean { try { val p1 = ProcessBuilder("printenv", "WSL_DISTRO_NAME").start() if (!p1.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException() if (p1.exitValue() == 0 && String(p1.inputStream.readBytes()).trim().isNotEmpty()) { return true } val p2 = ProcessBuilder("printenv", "IS_WSL").start() if (!p2.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException() if (p2.exitValue() == 0 && String(p2.inputStream.readBytes()).trim().isNotEmpty()) { return true } } catch (ignore: Exception) { // ignore } return false } fun isWindows(): Boolean { return OS_NAME.lowercase().startsWith("windows") } /** * Returns major version of Java, e.g. 8, 11, 17, 21. */ fun getJavaVersion(): Int { // Adapted from https://stackoverflow.com/a/2591122/7009800 val version = System.getProperty("java.version") return if (version.startsWith("1.")) { version.substring(2, 3).toInt() } else { val dot = version.indexOf(".") if (dot != -1) version.substring(0, dot).toInt() else 0 } } } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/PrintUtils.kt ================================================ package maestro.device.util import org.slf4j.LoggerFactory /** * Simplified version of PrintUtils for DeviceService */ object PrintUtils { private val logger = LoggerFactory.getLogger(PrintUtils::class.java) fun message(message: String) { logger.info(message) println(message) } } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/SimctlList.kt ================================================ package maestro.device.util import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class SimctlList( val devicetypes: List, val runtimes: List, val devices: Map>, val pairs: Map, ) { @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceType( val identifier: String, val name: String, val bundlePath: String, val productFamily: String, val maxRuntimeVersion: Long?, val maxRuntimeVersionString: String?, val minRuntimeVersion: Long?, val minRuntimeVersionString: String?, val modelIdentifier: String?, ) @JsonIgnoreProperties(ignoreUnknown = true) data class Runtime( val bundlePath: String, val buildversion: String, val platform: String?, val runtimeRoot: String, val identifier: String, val version: String, val isInternal: Boolean, val isAvailable: Boolean, val name: String, val supportedDeviceTypes: List, ) @JsonIgnoreProperties(ignoreUnknown = true) data class Device( val name: String, val dataPath: String?, val logPath: String?, val udid: String, val isAvailable: Boolean, val deviceTypeIdentifier: String?, val state: String, val availabilityError: String?, ) @JsonIgnoreProperties(ignoreUnknown = true) data class Pair( val watch: Device, val phone: Device, val state: String, ) } ================================================ FILE: maestro-client/src/main/java/maestro/device/util/SystemInfo.kt ================================================ package maestro.device.util object SystemInfo { fun getJavaVersion(): Int { // Adapted from https://stackoverflow.com/a/2591122/7009800 val version = System.getProperty("java.version") return if (version.startsWith("1.")) { version.substring(2, 3).toInt() } else { val dot = version.indexOf(".") if (dot != -1) version.substring(0, dot).toInt() else 0 } } } ================================================ FILE: maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt ================================================ /* * * Copyright (c) 2022 mobile.dev inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * */ package maestro.drivers import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.google.protobuf.ByteString import dadb.AdbShellPacket import dadb.AdbShellResponse import dadb.AdbShellStream import dadb.Dadb import io.grpc.ManagedChannelBuilder import io.grpc.Metadata import io.grpc.Status import io.grpc.StatusRuntimeException import maestro.* import maestro.MaestroDriverStartupException.AndroidDriverTimeoutException import maestro.MaestroDriverStartupException.AndroidInstrumentationSetupFailure import maestro.UiElement.Companion.toUiElementOrNull import maestro.android.AndroidAppFiles import maestro.android.AndroidLaunchArguments.toAndroidLaunchArguments import maestro.android.chromedevtools.AndroidWebViewHierarchyClient import maestro.device.DeviceOrientation import maestro.device.Platform import maestro.utils.BlockingStreamObserver import maestro.utils.MaestroTimer import maestro.utils.Metrics import maestro.utils.MetricsProvider import maestro.utils.ScreenshotUtils import maestro.utils.StringUtils.toRegexSafe import maestro_android.* import net.dongliu.apk.parser.ApkFile import okio.* import org.slf4j.LoggerFactory import org.w3c.dom.Element import org.w3c.dom.Node import java.io.File import java.io.IOException import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import javax.xml.parsers.DocumentBuilderFactory import kotlin.io.use private val logger = LoggerFactory.getLogger(Maestro::class.java) private const val DefaultDriverHostPort = 7001 class AndroidDriver( private val dadb: Dadb, hostPort: Int? = null, private var emulatorName: String = "", private val reinstallDriver: Boolean = true, private val metricsProvider: Metrics = MetricsProvider.getInstance(), ) : Driver { private var portForwarder: AutoCloseable? = null private var open = false private val hostPort: Int = hostPort ?: DefaultDriverHostPort private val metrics = metricsProvider.withPrefix("maestro.driver").withTags(mapOf("platform" to "android", "emulatorName" to emulatorName)) private val channel = ManagedChannelBuilder.forAddress("localhost", this.hostPort) .usePlaintext() .keepAliveTime(2, TimeUnit.MINUTES) .keepAliveTimeout(20, TimeUnit.SECONDS) .keepAliveWithoutCalls(true) .build() private val blockingStub = MaestroDriverGrpc.newBlockingStub(channel) private val blockingStubWithTimeout get() = blockingStub.withDeadlineAfter(120, TimeUnit.SECONDS) private val asyncStub = MaestroDriverGrpc.newStub(channel) private val documentBuilderFactory = DocumentBuilderFactory.newInstance() private val androidWebViewHierarchyClient = AndroidWebViewHierarchyClient(dadb) private var instrumentationSession: AdbShellStream? = null private var proxySet = false private var isLocationMocked = false private var chromeDevToolsEnabled = false override fun name(): String { return "Android Device ($dadb)" } override fun open() { allocateForwarder() installMaestroApks() startInstrumentationSession(hostPort) try { awaitLaunch() } catch (ignored: InterruptedException) { instrumentationSession?.close() return } } private fun startInstrumentationSession(port: Int = 7001) { val startTime = System.currentTimeMillis() val apiLevel = getDeviceApiLevel() val instrumentationCommand = buildString { append("am instrument -w ") if (apiLevel >= 26) append("-m ") append("-e debug false ") append("-e class 'dev.mobile.maestro.MaestroDriverService#grpcServer' ") append("-e port $port ") append("dev.mobile.maestro.test/androidx.test.runner.AndroidJUnitRunner &\n") } open = true while (System.currentTimeMillis() - startTime < getStartupTimeout()) { instrumentationSession = dadb.openShell(instrumentationCommand) if (instrumentationSession.successfullyStarted()) { return } instrumentationSession?.close() Thread.sleep(100) } throw AndroidInstrumentationSetupFailure("Maestro instrumentation could not be initialized") } private fun getDeviceApiLevel(): Int { val response = dadb.openShell("getprop ro.build.version.sdk").readAll() if (response.exitCode != 0) { throw IOException("Failed to get device API level: ${response.errorOutput}") } return response.output.trim().toIntOrNull() ?: throw IOException("Invalid API level: ${response.output}") } private fun allocateForwarder() { portForwarder?.close() portForwarder = dadb.tcpForward( hostPort, hostPort ) } private fun awaitLaunch() { val startTime = System.currentTimeMillis() while (System.currentTimeMillis() - startTime < getStartupTimeout()) { runCatching { dadb.open("tcp:$hostPort").close() return } Thread.sleep(100) } throw AndroidDriverTimeoutException("Maestro Android driver did not start up in time --- emulator [ ${emulatorName} ] & port [ dadb.open( tcp:${hostPort} ) ]") } override fun close() { if (proxySet) { resetProxy() } if (isLocationMocked) { blockingStubWithTimeout.disableLocationUpdates(emptyRequest { }) isLocationMocked = false } LOGGER.info("[Start] close port forwarder") portForwarder?.close() LOGGER.info("[Done] close port forwarder") LOGGER.info("[Start] Uninstall driver from device") if (reinstallDriver) { uninstallMaestroDriverApp() } if (reinstallDriver) { uninstallMaestroServerApp() } LOGGER.info("[Done] Uninstall driver from device") LOGGER.info("[Start] Close instrumentation session") instrumentationSession?.close() instrumentationSession = null LOGGER.info("[Done] Close instrumentation session") LOGGER.info("[Start] Shutdown GRPC channel") channel.shutdown() LOGGER.info("[Done] Shutdown GRPC channel") androidWebViewHierarchyClient.close() if (!channel.awaitTermination(5, TimeUnit.SECONDS)) { throw TimeoutException("Couldn't close Maestro Android driver due to gRPC timeout") } } override fun deviceInfo(): DeviceInfo { return runDeviceCall("deviceInfo") { val response = blockingStubWithTimeout.deviceInfo(deviceInfoRequest {}) DeviceInfo( platform = Platform.ANDROID, widthPixels = response.widthPixels, heightPixels = response.heightPixels, widthGrid = response.widthPixels, heightGrid = response.heightPixels, ) } } override fun launchApp( appId: String, launchArguments: Map, ) { metrics.measured("operation", mapOf("command" to "launchApp", "appId" to appId)) { if(!open) // pick device flow, no open() invocation open() if (!isPackageInstalled(appId)) { throw IllegalArgumentException("Package $appId is not installed") } val arguments = launchArguments.toAndroidLaunchArguments() runDeviceCall("launchApp") { blockingStubWithTimeout.launchApp( launchAppRequest { this.packageName = appId this.arguments.addAll(arguments) } ) ?: throw IllegalStateException("Maestro driver failed to launch app") } } } override fun stopApp(appId: String) { metrics.measured("operation", mapOf("command" to "stopApp", "appId" to appId)) { // Note: If the package does not exist, this call does *not* throw an exception shell("am force-stop $appId") } } override fun killApp(appId: String) { metrics.measured("operation", mapOf("command" to "killApp", "appId" to appId)) { // Kill is the adb command needed to trigger System-initiated Process Death shell("am kill $appId") } } override fun clearAppState(appId: String) { metrics.measured("operation", mapOf("command" to "clearAppState", "appId" to appId)) { if (!isPackageInstalled(appId)) { return@measured } shell("pm clear $appId") } } override fun clearKeychain() { // No op } override fun tap(point: Point) { metrics.measured("operation", mapOf("command" to "tap")) { runDeviceCall("tap") { blockingStubWithTimeout.tap( tapRequest { x = point.x y = point.y } ) ?: throw IllegalStateException("Response can't be null") } } } override fun longPress(point: Point) { metrics.measured("operation", mapOf("command" to "longPress")) { dadb.shell("input swipe ${point.x} ${point.y} ${point.x} ${point.y} 3000") } } override fun pressKey(code: KeyCode) { metrics.measured("operation", mapOf("command" to "pressKey")) { val intCode: Int = when (code) { KeyCode.ENTER -> 66 KeyCode.BACKSPACE -> 67 KeyCode.BACK -> 4 KeyCode.VOLUME_UP -> 24 KeyCode.VOLUME_DOWN -> 25 KeyCode.HOME -> 3 KeyCode.LOCK -> 276 KeyCode.REMOTE_UP -> 19 KeyCode.REMOTE_DOWN -> 20 KeyCode.REMOTE_LEFT -> 21 KeyCode.REMOTE_RIGHT -> 22 KeyCode.REMOTE_CENTER -> 23 KeyCode.REMOTE_PLAY_PAUSE -> 85 KeyCode.REMOTE_STOP -> 86 KeyCode.REMOTE_NEXT -> 87 KeyCode.REMOTE_PREVIOUS -> 88 KeyCode.REMOTE_REWIND -> 89 KeyCode.REMOTE_FAST_FORWARD -> 90 KeyCode.POWER -> 26 KeyCode.ESCAPE -> 111 KeyCode.TAB -> 62 KeyCode.REMOTE_SYSTEM_NAVIGATION_UP -> 280 KeyCode.REMOTE_SYSTEM_NAVIGATION_DOWN -> 281 KeyCode.REMOTE_BUTTON_A -> 96 KeyCode.REMOTE_BUTTON_B -> 97 KeyCode.REMOTE_MENU -> 82 KeyCode.TV_INPUT -> 178 KeyCode.TV_INPUT_HDMI_1 -> 243 KeyCode.TV_INPUT_HDMI_2 -> 244 KeyCode.TV_INPUT_HDMI_3 -> 245 } dadb.shell("input keyevent $intCode") Thread.sleep(300) } } override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode { return metrics.measured("operation", mapOf("command" to "contentDescriptor")) { val response = callViewHierarchy() val document = documentBuilderFactory .newDocumentBuilder() .parse(response.hierarchy.byteInputStream()) val baseTree = mapHierarchy(document) val treeNode = androidWebViewHierarchyClient.augmentHierarchy(baseTree, chromeDevToolsEnabled) if (excludeKeyboardElements) { treeNode.excludeKeyboardElements() ?: treeNode } else { treeNode } } } private fun TreeNode.excludeKeyboardElements(): TreeNode? { val filtered = children.mapNotNull { it.excludeKeyboardElements() }.toList() val resourceId = attributes["resource-id"] if (resourceId != null && resourceId.startsWith("com.google.android.inputmethod.latin:id/")) { return null } return TreeNode( attributes = attributes, children = filtered, clickable = clickable, enabled = enabled, focused = focused, checked = checked, selected = selected ) } private fun callViewHierarchy(attempt: Int = 1): MaestroAndroid.ViewHierarchyResponse { return try { blockingStubWithTimeout.viewHierarchy(viewHierarchyRequest {}) } catch (throwable: StatusRuntimeException) { val status = Status.fromThrowable(throwable) when (status.code) { Status.Code.DEADLINE_EXCEEDED -> { LOGGER.error("Timeout while fetching view hierarchy") throw throwable } Status.Code.UNAVAILABLE -> { if (throwable.cause is IOException || throwable.message?.contains("io exception", ignoreCase = true) == true) { LOGGER.error("Not able to reach the gRPC server while fetching view hierarchy") } else { LOGGER.error("Received UNAVAILABLE status with message: ${throwable.message}") } } else -> { LOGGER.error("Unexpected error: ${status.code} - ${throwable.message}") } } // There is a bug in Android UiAutomator that rarely throws an NPE while dumping a view hierarchy. // Trying to recover once by giving it a bit of time to settle. LOGGER.error("Failed to get view hierarchy: ${status.description}", throwable) if (attempt > 0) { MaestroTimer.sleep(MaestroTimer.Reason.BUFFER, 1000L) return callViewHierarchy(attempt - 1) } throw throwable } } override fun scrollVertical() { metrics.measured("operation", mapOf("command" to "scrollVertical")) { swipe(SwipeDirection.UP, 400) } } override fun isKeyboardVisible(): Boolean { return metrics.measured("operation", mapOf("command" to "isKeyboardVisible")) { val root = contentDescriptor().let { val deviceInfo = deviceInfo() val filtered = it.filterOutOfBounds( width = deviceInfo.widthGrid, height = deviceInfo.heightGrid ) filtered ?: it } "com.google.android.inputmethod.latin:id" in jacksonObjectMapper().writeValueAsString(root) } } override fun swipe(start: Point, end: Point, durationMs: Long) { dadb.shell("input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs") } override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) { metrics.measured("operation", mapOf("command" to "swipeWithDirection", "direction" to swipeDirection.name, "durationMs" to durationMs.toString())) { val deviceInfo = deviceInfo() when (swipeDirection) { SwipeDirection.UP -> { val startX = (deviceInfo.widthGrid * 0.5f).toInt() val startY = (deviceInfo.heightGrid * 0.5f).toInt() val endX = (deviceInfo.widthGrid * 0.5f).toInt() val endY = (deviceInfo.heightGrid * 0.1f).toInt() directionalSwipe( durationMs, Point(startX, startY), Point(endX, endY) ) } SwipeDirection.DOWN -> { val startX = (deviceInfo.widthGrid * 0.5f).toInt() val startY = (deviceInfo.heightGrid * 0.2f).toInt() val endX = (deviceInfo.widthGrid * 0.5f).toInt() val endY = (deviceInfo.heightGrid * 0.9f).toInt() directionalSwipe( durationMs, Point(startX, startY), Point(endX, endY) ) } SwipeDirection.RIGHT -> { val startX = (deviceInfo.widthGrid * 0.1f).toInt() val startY = (deviceInfo.heightGrid * 0.5f).toInt() val endX = (deviceInfo.widthGrid * 0.9f).toInt() val endY = (deviceInfo.heightGrid * 0.5f).toInt() directionalSwipe( durationMs, Point(startX, startY), Point(endX, endY) ) } SwipeDirection.LEFT -> { val startX = (deviceInfo.widthGrid * 0.9f).toInt() val startY = (deviceInfo.heightGrid * 0.5f).toInt() val endX = (deviceInfo.widthGrid * 0.1f).toInt() val endY = (deviceInfo.heightGrid * 0.5f).toInt() directionalSwipe( durationMs, Point(startX, startY), Point(endX, endY) ) } } } } override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) { metrics.measured("operation", mapOf("command" to "swipeWithElementPoint", "direction" to direction.name, "durationMs" to durationMs.toString())) { val deviceInfo = deviceInfo() when (direction) { SwipeDirection.UP -> { val endY = (deviceInfo.heightGrid * 0.1f).toInt() directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) } SwipeDirection.DOWN -> { val endY = (deviceInfo.heightGrid * 0.9f).toInt() directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) } SwipeDirection.RIGHT -> { val endX = (deviceInfo.widthGrid * 0.9f).toInt() directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) } SwipeDirection.LEFT -> { val endX = (deviceInfo.widthGrid * 0.1f).toInt() directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) } } } } private fun directionalSwipe(durationMs: Long, start: Point, end: Point) { metrics.measured("operation", mapOf("command" to "directionalSwipe", "durationMs" to durationMs.toString())) { dadb.shell("input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs") } } override fun backPress() { metrics.measured("operation", mapOf("command" to "backPress")) { dadb.shell("input keyevent 4") Thread.sleep(300) } } override fun hideKeyboard() { metrics.measured("operation", mapOf("command" to "hideKeyboard")) { dadb.shell("input keyevent 4") // 'Back', which dismisses the keyboard before handing over to navigation Thread.sleep(300) waitForAppToSettle(null, null) } } override fun takeScreenshot(out: Sink, compressed: Boolean) { metrics.measured("operation", mapOf("command" to "takeScreenshot", "compressed" to compressed.toString())) { runDeviceCall("takeScreenshot") { val response = blockingStubWithTimeout.screenshot(screenshotRequest {}) out.buffer().use { it.write(response.bytes.toByteArray()) } } } } override fun startScreenRecording(out: Sink): ScreenRecording { return metrics.measured("operation", mapOf("command" to "startScreenRecording")) { val deviceScreenRecordingPath = "/sdcard/maestro-screenrecording.mp4" val future = CompletableFuture.runAsync({ val timeLimit = if (getDeviceApiLevel() >= 34) "--time-limit 0" else "" try { shell("screenrecord $timeLimit --bit-rate '100000' $deviceScreenRecordingPath") } catch (e: IOException) { throw IOException( "Failed to capture screen recording on the device. Note that some Android emulators do not support screen recording. " + "Try using a different Android emulator (eg. Pixel 5 / API 30)", e, ) } }, Executors.newSingleThreadExecutor()) object : ScreenRecording { override fun close() { dadb.shell("killall -INT screenrecord") // Ignore exit code future.get() Thread.sleep(3000) dadb.pull(out, deviceScreenRecordingPath) } } } } override fun inputText(text: String) { metrics.measured("operation", mapOf("command" to "inputText")) { runDeviceCall("inputText") { blockingStubWithTimeout.inputText(inputTextRequest { this.text = text }) ?: throw IllegalStateException("Input Response can't be null") } } } override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) { metrics.measured("operation", mapOf("command" to "openLink", "appId" to appId, "autoVerify" to autoVerify.toString(), "browser" to browser.toString())) { if (browser) { openBrowser(link) } else { dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"") } if (autoVerify) { autoVerifyApp(appId) } } } private fun autoVerifyApp(appId: String?) { if (appId != null) { autoVerifyWithAppName(appId) } autoVerifyChromeAgreement() } private fun autoVerifyWithAppName(appId: String) { val appNameResult = runCatching { val apkFile = AndroidAppFiles.getApkFile(dadb, appId) val appName = ApkFile(apkFile).apkMeta.name apkFile.delete() appName } if (appNameResult.isSuccess) { val appName = appNameResult.getOrThrow() waitUntilScreenIsStatic(3000) val appNameElement = filterByText(appName) if (appNameElement != null) { tap(appNameElement.bounds.center()) filterById("android:id/button_once")?.let { tap(it.bounds.center()) } } else { val openWithAppElement = filterByText(".*$appName.*") if (openWithAppElement != null) { filterById("android:id/button_once")?.let { tap(it.bounds.center()) } } } } } private fun autoVerifyChromeAgreement() { filterById("com.android.chrome:id/terms_accept")?.let { tap(it.bounds.center()) } waitForAppToSettle(null, null) filterById("com.android.chrome:id/negative_button")?.let { tap(it.bounds.center()) } } private fun filterByText(textRegex: String): UiElement? { val textMatcher = Filters.textMatches(textRegex.toRegexSafe(REGEX_OPTIONS)) val filterFunc = Filters.deepestMatchingElement(textMatcher) return filterFunc(contentDescriptor().aggregate()).firstOrNull()?.toUiElementOrNull() } private fun filterById(idRegex: String): UiElement? { val idMatcher = Filters.idMatches(idRegex.toRegexSafe(REGEX_OPTIONS)) val filterFunc = Filters.deepestMatchingElement(idMatcher) return filterFunc(contentDescriptor().aggregate()).firstOrNull()?.toUiElementOrNull() } private fun openBrowser(link: String) { val installedPackages = installedPackages() when { installedPackages.contains("com.android.chrome") -> { dadb.shell("am start -a android.intent.action.VIEW -d \"$link\" com.android.chrome") } installedPackages.contains("org.mozilla.firefox") -> { dadb.shell("am start -a android.intent.action.VIEW -d \"$link\" org.mozilla.firefox") } else -> { dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"") } } } private fun installedPackages() = shell("pm list packages").split("\n") .map { line: String -> line.split(":".toRegex()).toTypedArray() } .filter { parts: Array -> parts.size == 2 } .map { parts: Array -> parts[1] } override fun setLocation(latitude: Double, longitude: Double) { metrics.measured("operation", mapOf("command" to "setLocation")) { if (!isLocationMocked) { LOGGER.info("[Start] Setting up for mocking location $latitude, $longitude") shell("pm grant dev.mobile.maestro android.permission.ACCESS_FINE_LOCATION") shell("pm grant dev.mobile.maestro android.permission.ACCESS_COARSE_LOCATION") shell("appops set dev.mobile.maestro android:mock_location allow") runDeviceCall("enableMockLocationProviders") { blockingStubWithTimeout.enableMockLocationProviders(emptyRequest { }) } LOGGER.info("[Done] Setting up for mocking location $latitude, $longitude") isLocationMocked = true } runDeviceCall("setLocation") { blockingStubWithTimeout.setLocation( setLocationRequest { this.latitude = latitude this.longitude = longitude } ) ?: error("Set Location Response can't be null") } } } override fun setOrientation(orientation: DeviceOrientation) { // Disable accelerometer based rotation before overriding orientation dadb.shell("settings put system accelerometer_rotation 0") when(orientation) { DeviceOrientation.PORTRAIT -> dadb.shell("settings put system user_rotation 0") DeviceOrientation.LANDSCAPE_LEFT -> dadb.shell("settings put system user_rotation 1") DeviceOrientation.UPSIDE_DOWN -> dadb.shell("settings put system user_rotation 2") DeviceOrientation.LANDSCAPE_RIGHT -> dadb.shell("settings put system user_rotation 3") } } override fun eraseText(charactersToErase: Int) { metrics.measured("operation", mapOf("command" to "eraseText", "charactersToErase" to charactersToErase.toString())) { runDeviceCall("eraseText") { blockingStubWithTimeout.eraseAllText( eraseAllTextRequest { this.charactersToErase = charactersToErase } ) ?: throw IllegalStateException("Erase Response can't be null") } } } override fun setProxy(host: String, port: Int) { metrics.measured("operation", mapOf("command" to "setProxy")) { shell("""settings put global http_proxy "${host}:${port}"""") proxySet = true } } override fun resetProxy() { metrics.measured("operation", mapOf("command" to "resetProxy")) { shell("settings put global http_proxy :0") } } override fun isShutdown(): Boolean { return metrics.measured("operation", mapOf("command" to "isShutdown")) { channel.isShutdown } } override fun isUnicodeInputSupported(): Boolean { return false } override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy? { return metrics.measured("operation", mapOf("command" to "waitForAppToSettle", "appId" to appId, "timeoutMs" to timeoutMs.toString())) { if (appId != null) { waitForWindowToSettle(appId, initialHierarchy, timeoutMs) } else { ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) } } } private fun waitForWindowToSettle( appId: String, initialHierarchy: ViewHierarchy?, timeoutMs: Int? = null ): ViewHierarchy { val endTime = System.currentTimeMillis() + WINDOW_UPDATE_TIMEOUT_MS var hierarchy: ViewHierarchy? = null do { runDeviceCall("isWindowUpdating") { val windowUpdating = blockingStubWithTimeout.isWindowUpdating(checkWindowUpdatingRequest { this.appId = appId }).isWindowUpdating if (windowUpdating) { hierarchy = ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) } } } while (System.currentTimeMillis() < endTime) return hierarchy ?: ScreenshotUtils.waitForAppToSettle(initialHierarchy, this) } override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean { return metrics.measured("operation", mapOf("command" to "waitUntilScreenIsStatic", "timeoutMs" to timeoutMs.toString())) { ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this) } } override fun capabilities(): List { return metrics.measured("operation", mapOf("command" to "capabilities")) { listOf( Capability.FAST_HIERARCHY ) } } override fun setPermissions(appId: String, permissions: Map) { metrics.measured("operation", mapOf("command" to "setPermissions", "appId" to appId)) { val mutable = permissions.toMutableMap() mutable.remove("all")?.let { value -> setAllPermissions(appId, value) } mutable.forEach { permission -> val permissionValue = translatePermissionValue(permission.value) translatePermissionName(permission.key).forEach { permissionName -> setPermissionInternal(appId, permissionName, permissionValue) } } } } override fun addMedia(mediaFiles: List) { metrics.measured("operation", mapOf("command" to "addMedia", "mediaFilesCount" to mediaFiles.size.toString())) { LOGGER.info("[Start] Adding media files") mediaFiles.forEach { addMediaToDevice(it) } LOGGER.info("[Done] Adding media files") } } override fun isAirplaneModeEnabled(): Boolean { return metrics.measured("operation", mapOf("command" to "isAirplaneModeEnabled")) { when (val result = shell("cmd connectivity airplane-mode").trim()) { "No shell command implementation.", "" -> { LOGGER.debug("Falling back to old airplane mode read method") when (val fallbackResult = shell("settings get global airplane_mode_on").trim()) { "0" -> false "1" -> true else -> throw IllegalStateException("Received invalid response from while trying to read airplane mode state: $fallbackResult") } } "disabled" -> false "enabled" -> true else -> throw IllegalStateException("Received invalid response while trying to read airplane mode state: $result") } } } override fun setAirplaneMode(enabled: Boolean) { metrics.measured("operation", mapOf("command" to "setAirplaneMode", "enabled" to enabled.toString())) { // fallback to old way on API < 28 if (getDeviceApiLevel() < 28) { val num = if (enabled) 1 else 0 shell("settings put global airplane_mode_on $num") // We need to broadcast the change to really apply it broadcastAirplaneMode(enabled) return@measured } val value = if (enabled) "enable" else "disable" shell("cmd connectivity airplane-mode $value") } } private fun broadcastAirplaneMode(enabled: Boolean) { val command = "am broadcast -a android.intent.action.AIRPLANE_MODE --ez state $enabled" try { shell(command) } catch (e: IOException) { if (e.message?.contains("Security exception: Permission Denial:") == true) { try { shell("su root $command") } catch (e: IOException) { throw MaestroException.NoRootAccess("Failed to broadcast airplane mode change. Make sure to run an emulator with root access for API < 28") } } } } override fun setAndroidChromeDevToolsEnabled(enabled: Boolean) { this.chromeDevToolsEnabled = enabled } fun setDeviceLocale(country: String, language: String): Int { return metrics.measured("operation", mapOf("command" to "setDeviceLocale", "country" to country, "language" to language)) { dadb.shell("pm grant dev.mobile.maestro android.permission.CHANGE_CONFIGURATION") val response = dadb.shell("am broadcast -a dev.mobile.maestro.locale -n dev.mobile.maestro/.receivers.LocaleSettingReceiver --es lang $language --es country $country") extractSetLocaleResult(response.output) } } private fun extractSetLocaleResult(result: String): Int { val regex = Regex("result=(-?\\d+)") val match = regex.find(result) return match?.groups?.get(1)?.value?.toIntOrNull() ?: -1 } private fun addMediaToDevice(mediaFile: File) { val namedSource = NamedSource( mediaFile.name, mediaFile.source(), mediaFile.extension, mediaFile.path ) val responseObserver = BlockingStreamObserver() val requestStream = asyncStub.addMedia(responseObserver) val ext = MediaExt.values().firstOrNull { it.extName == namedSource.extension } ?: throw IllegalArgumentException( "Extension .${namedSource.extension} is not yet supported for add media" ) val buffer = Buffer() val source = namedSource.source while (source.read(buffer, CHUNK_SIZE) != -1L) { requestStream.onNext( addMediaRequest { this.payload = payload { data = ByteString.copyFrom(buffer.readByteArray()) } this.mediaName = namedSource.name this.mediaExt = ext.extName } ) buffer.clear() } source.close() requestStream.onCompleted() responseObserver.awaitResult() } private fun setAllPermissions(appId: String, permissionValue: String) { val permissionsResult = runCatching { val apkFile = AndroidAppFiles.getApkFile(dadb, appId) val permissions = ApkFile(apkFile).apkMeta.usesPermissions apkFile.delete() permissions } if (permissionsResult.isSuccess) { permissionsResult.getOrNull()?.let { it.forEach { permission -> setPermissionInternal(appId, permission, translatePermissionValue(permissionValue)) } } } } private fun setPermissionInternal(appId: String, permission: String, permissionValue: String) { try { shell("pm $permissionValue $appId $permission") } catch (exception: Exception) { // Ignore if it's something that the user doesn't have control over (e.g. you can't grant / deny INTERNET) if (exception.message?.contains("is not a changeable permission type") == false) { // Debug level is fine. // We don't need to be loud about this. IOExceptions were already caught in shell(..) // Remaining issues are likely due to "all" containing permissions that the app doesn't support. logger.debug("Failed to set permission $permission for app $appId: ${exception.message}") } } } private fun translatePermissionName(name: String): List { return when (name) { "location" -> listOf( "android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION", ) "camera" -> listOf("android.permission.CAMERA") "contacts" -> listOf( "android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS" ) "phone" -> listOf( "android.permission.CALL_PHONE", "android.permission.ANSWER_PHONE_CALLS", ) "microphone" -> listOf( "android.permission.RECORD_AUDIO" ) "bluetooth" -> listOf( "android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_SCAN", ) "storage" -> listOf( "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE" ) "notifications" -> listOf( "android.permission.POST_NOTIFICATIONS" ) "medialibrary" -> listOf( "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.READ_MEDIA_AUDIO", "android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_VIDEO" ) "calendar" -> listOf( "android.permission.WRITE_CALENDAR", "android.permission.READ_CALENDAR" ) "sms" -> listOf( "android.permission.READ_SMS", "android.permission.RECEIVE_SMS", "android.permission.SEND_SMS" ) else -> listOf(name.replace("[^A-Za-z0-9._]+".toRegex(), "")) } } private fun translatePermissionValue(value: String): String { return when (value) { "allow" -> "grant" "deny" -> "revoke" "unset" -> "revoke" else -> "revoke" } } private fun mapHierarchy(node: Node): TreeNode { val attributes = if (node is Element) { val attributesBuilder = mutableMapOf() if (node.hasAttribute("text")) { val text = node.getAttribute("text") attributesBuilder["text"] = text } if (node.hasAttribute("content-desc")) { attributesBuilder["accessibilityText"] = node.getAttribute("content-desc") } if (node.hasAttribute("hintText")) { attributesBuilder["hintText"] = node.getAttribute("hintText") } if (node.hasAttribute("class") && node.getAttribute("class") == TOAST_CLASS_NAME) { attributesBuilder["ignoreBoundsFiltering"] = true.toString() } else { attributesBuilder["ignoreBoundsFiltering"] = false.toString() } if (node.hasAttribute("resource-id")) { attributesBuilder["resource-id"] = node.getAttribute("resource-id") } if (node.hasAttribute("clickable")) { attributesBuilder["clickable"] = node.getAttribute("clickable") } if (node.hasAttribute("bounds")) { attributesBuilder["bounds"] = node.getAttribute("bounds") } if (node.hasAttribute("enabled")) { attributesBuilder["enabled"] = node.getAttribute("enabled") } if (node.hasAttribute("focused")) { attributesBuilder["focused"] = node.getAttribute("focused") } if (node.hasAttribute("checked")) { attributesBuilder["checked"] = node.getAttribute("checked") } if (node.hasAttribute("scrollable")) { attributesBuilder["scrollable"] = node.getAttribute("scrollable") } if (node.hasAttribute("selected")) { attributesBuilder["selected"] = node.getAttribute("selected") } if (node.hasAttribute("class")) { attributesBuilder["class"] = node.getAttribute("class") } if (node.hasAttribute("important-for-accessibility")) { attributesBuilder["important-for-accessibility"] = node.getAttribute("important-for-accessibility") } if (node.hasAttribute("error")) { attributesBuilder["error"] = node.getAttribute("error") } attributesBuilder } else { emptyMap() } val children = mutableListOf() val childNodes = node.childNodes (0 until childNodes.length).forEach { i -> children += mapHierarchy(childNodes.item(i)) } return TreeNode( attributes = attributes.toMutableMap(), children = children, clickable = node.getBoolean("clickable"), enabled = node.getBoolean("enabled"), focused = node.getBoolean("focused"), checked = node.getBoolean("checked"), selected = node.getBoolean("selected"), ) } private fun Node.getBoolean(name: String): Boolean? { return (this as? Element) ?.getAttribute(name) ?.let { it == "true" } } fun installMaestroDriverApp() { metrics.measured("operation", mapOf("command" to "installMaestroDriverApp")) { if (reinstallDriver) { uninstallMaestroDriverApp() } else if (isPackageInstalled("dev.mobile.maestro")) { return@measured } val maestroAppApk = File.createTempFile("maestro-app", ".apk") Maestro::class.java.getResourceAsStream("/maestro-app.apk")?.let { val bufferedSink = maestroAppApk.sink().buffer() bufferedSink.writeAll(it.source()) bufferedSink.flush() } install(maestroAppApk) if (!isPackageInstalled("dev.mobile.maestro")) { throw IllegalStateException("dev.mobile.maestro was not installed") } maestroAppApk.delete() } } private fun installMaestroServerApp() { if (reinstallDriver) { uninstallMaestroServerApp() } else if (isPackageInstalled("dev.mobile.maestro.test")) { return } val maestroServerApk = File.createTempFile("maestro-server", ".apk") Maestro::class.java.getResourceAsStream("/maestro-server.apk")?.let { val bufferedSink = maestroServerApk.sink().buffer() bufferedSink.writeAll(it.source()) bufferedSink.flush() } install(maestroServerApk) if (!isPackageInstalled("dev.mobile.maestro.test")) { throw IllegalStateException("dev.mobile.maestro.test was not installed") } maestroServerApk.delete() } private fun installMaestroApks() { installMaestroDriverApp() installMaestroServerApp() } fun uninstallMaestroDriverApp() { metrics.measured("operation", mapOf("command" to "uninstallMaestroDriverApp")) { try { if (isPackageInstalled("dev.mobile.maestro")) { uninstall("dev.mobile.maestro") } } catch (e: IOException) { logger.warn("Failed to check or uninstall maestro driver app: ${e.message}") // Continue with cleanup even if we can't check package status try { uninstall("dev.mobile.maestro") } catch (e2: IOException) { logger.warn("Failed to uninstall maestro driver app: ${e2.message}") // Just log and continue, don't throw } } } } private fun uninstallMaestroServerApp() { try { if (isPackageInstalled("dev.mobile.maestro.test")) { uninstall("dev.mobile.maestro.test") } } catch (e: IOException) { logger.warn("Failed to check or uninstall maestro server app: ${e.message}") // Continue with cleanup even if we can't check package status try { uninstall("dev.mobile.maestro.test") } catch (e2: IOException) { logger.warn("Failed to uninstall maestro server app: ${e2.message}") // Just log and continue, don't throw } } } private fun uninstallMaestroApks() { uninstallMaestroDriverApp() uninstallMaestroServerApp() } private fun install(apkFile: File) { try { dadb.install(apkFile) } catch (installError: IOException) { throw IOException("Failed to install apk " + apkFile + ": " + installError.message, installError) } } private fun uninstall(packageName: String) { try { dadb.uninstall(packageName) } catch (error: IOException) { throw IOException("Failed to uninstall package " + packageName + ": " + error.message, error) } } private fun isPackageInstalled(packageName: String): Boolean { try { val output: String = shell("pm list packages --user 0 $packageName") return output.split("\n".toRegex()) .map { line -> line.split(":".toRegex()) } .filter { parts -> parts.size == 2 } .map { parts -> parts[1] } .any { linePackageName -> linePackageName == packageName } } catch (e: IOException) { logger.warn("Failed to check if package $packageName is installed: ${e.message}") // If we can't check, we'll assume it's not installed throw e } } private fun shell(command: String): String { val response: AdbShellResponse = try { dadb.shell(command) } catch (e: IOException) { throw IOException(command, e) } if (response.exitCode != 0) { throw IOException("$command: ${response.allOutput}") } return response.output } private fun getStartupTimeout(): Long = runCatching { System.getenv(MAESTRO_DRIVER_STARTUP_TIMEOUT).toLong() }.getOrDefault(SERVER_LAUNCH_TIMEOUT_MS) private fun AdbShellStream?.successfullyStarted(): Boolean { val output = this?.read() return when { output is AdbShellPacket.StdError -> false output.toString().contains("FAILED", true) -> false output.toString().contains("UNABLE", true) -> false else -> true } } private fun runDeviceCall(callName: String, call: () -> T): T { return try { call() } catch (throwable: StatusRuntimeException) { val status = Status.fromThrowable(throwable) when (status.code) { Status.Code.DEADLINE_EXCEEDED -> { LOGGER.error("$callName call failed on android with $status", throwable) throw throwable } Status.Code.UNAVAILABLE -> { if (throwable.cause is IOException || throwable.message?.contains("io exception", ignoreCase = true) == true) { LOGGER.error("Not able to reach the gRPC server while processing $callName command") throw throwable } else { LOGGER.error("Received UNAVAILABLE status with message: ${throwable.message} while processing $callName command", throwable) throw throwable } } Status.Code.INTERNAL -> { val trailers = Status.trailersFromThrowable(throwable) val errorType = trailers?.get(ERROR_TYPE_KEY) val errorMsg = trailers?.get(ERROR_MSG_KEY) val errorCause = trailers?.get(ERROR_CAUSE_KEY) LOGGER.error("$callName call failed: type=$errorType, message=$errorMsg, cause=$errorCause", throwable) throw throwable.cause ?: throwable } else -> { LOGGER.error("Unexpected error during $callName: ${status.code} - ${throwable.message} and cause ${throwable.cause}", throwable) throw throwable } } } } companion object { private const val SERVER_LAUNCH_TIMEOUT_MS = 15000L private const val MAESTRO_DRIVER_STARTUP_TIMEOUT = "MAESTRO_DRIVER_STARTUP_TIMEOUT" private const val WINDOW_UPDATE_TIMEOUT_MS = 750 private val REGEX_OPTIONS = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE) private val ERROR_TYPE_KEY: Metadata.Key = Metadata.Key.of("error-type", Metadata.ASCII_STRING_MARSHALLER) private val ERROR_MSG_KEY: Metadata.Key = Metadata.Key.of("error-message", Metadata.ASCII_STRING_MARSHALLER) private val ERROR_CAUSE_KEY: Metadata.Key = Metadata.Key.of("error-cause", Metadata.ASCII_STRING_MARSHALLER) private val LOGGER = LoggerFactory.getLogger(AndroidDriver::class.java) private const val TOAST_CLASS_NAME = "android.widget.Toast" private const val SCREENSHOT_DIFF_THRESHOLD = 0.005 private const val CHUNK_SIZE = 1024L * 1024L * 3L } } ================================================ FILE: maestro-client/src/main/java/maestro/drivers/CdpWebDriver.kt ================================================ package maestro.drivers import CdpClient import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import kotlinx.coroutines.runBlocking import maestro.Capability import maestro.DeviceInfo import maestro.device.DeviceOrientation import maestro.Driver import maestro.KeyCode import maestro.Maestro import maestro.OnDeviceElementQuery import maestro.Point import maestro.ScreenRecording import maestro.SwipeDirection import maestro.TreeNode import maestro.ViewHierarchy import maestro.device.Platform import maestro.utils.ScreenshotUtils import maestro.web.record.JcodecVideoEncoder import maestro.web.record.WebScreenRecorder import okio.Sink import okio.buffer import org.openqa.selenium.By import org.openqa.selenium.JavascriptExecutor import org.openqa.selenium.Keys import org.openqa.selenium.WebDriver import org.openqa.selenium.WebElement import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeDriverService import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.chromium.ChromiumDriverLogLevel import org.openqa.selenium.devtools.HasDevTools import org.openqa.selenium.devtools.v144.emulation.Emulation import org.openqa.selenium.interactions.Actions import org.openqa.selenium.interactions.PointerInput import org.openqa.selenium.interactions.Sequence import org.openqa.selenium.remote.RemoteWebDriver import org.slf4j.LoggerFactory import java.io.File import java.net.URI import java.time.Duration import java.util.* import java.util.logging.Level import java.util.logging.Logger private const val SYNTHETIC_COORDINATE_SPACE_OFFSET = 100000 class CdpWebDriver( val isStudio: Boolean, private val isHeadless: Boolean = false, private val screenSize: String? ) : Driver { private lateinit var cdpClient: CdpClient private var seleniumDriver: org.openqa.selenium.WebDriver? = null private var maestroWebScript: String? = null private var lastSeenWindowHandles = setOf() private var injectedArguments: Map = emptyMap() private var webScreenRecorder: WebScreenRecorder? = null init { Maestro::class.java.getResourceAsStream("/maestro-web.js")?.let { it.bufferedReader().use { br -> maestroWebScript = br.readText() } } ?: error("Could not read maestro web script") } override fun name(): String { return "Chromium Desktop Browser (Experimental)" } override fun open() { seleniumDriver = createSeleniumDriver() try { seleniumDriver ?.let { it as? HasDevTools } ?.devTools ?.createSessionIfThereIsNotOne() } catch (e: Exception) { // Swallow the exception to avoid crashing the whole process. // Some implementations of Selenium do not support DevTools // and do not fail gracefully. } if (isStudio) { seleniumDriver?.get("https://maestro.mobile.dev") } } private fun createSeleniumDriver(): WebDriver { System.setProperty("webdriver.chrome.silentOutput", "true") System.setProperty(ChromeDriverService.CHROME_DRIVER_SILENT_OUTPUT_PROPERTY, "true") Logger.getLogger("org.openqa.selenium").level = Level.OFF Logger.getLogger("org.openqa.selenium.devtools.CdpVersionFinder").level = Level.OFF val driverService = ChromeDriverService.Builder() .withLogLevel(ChromiumDriverLogLevel.OFF) .build() val driver = ChromeDriver( driverService, ChromeOptions().apply { addArguments("--remote-allow-origins=*") addArguments("--disable-search-engine-choice-screen") addArguments("--lang=en") // Disable password management addArguments("--password-store=basic") val chromePrefs = hashMapOf( "credentials_enable_service" to false, "profile.password_manager_enabled" to false, "profile.password_manager_leak_detection" to false // important one ) setExperimentalOption("prefs", chromePrefs) setExperimentalOption("detach", true) if (isHeadless) { addArguments("--headless=new") if(screenSize != null){ addArguments("--window-size=" + screenSize.replace('x',',')) } else{ addArguments("--window-size=1024,768") } } } ) val options = driver.capabilities.getCapability("goog:chromeOptions") as Map val debuggerAddress = options["debuggerAddress"] as String val parts = debuggerAddress.split(":") cdpClient = CdpClient( host = parts[0], port = parts[1].toInt() ) return driver } private fun ensureOpen(): org.openqa.selenium.WebDriver { return seleniumDriver ?: error("Driver is not open") } private fun executeJS(js: String): Any? { return runBlocking { try { val target = cdpClient.listTargets().first() cdpClient.evaluate("$maestroWebScript", target) injectedArguments.forEach { (key, value) -> cdpClient.evaluate("$key = '$value'", target) } Thread.sleep(100) var resultStr = cdpClient.evaluate(js, target) // Convert from string to Map if needed return@runBlocking jacksonObjectMapper().readValue(resultStr, Any::class.java) } catch (e: Exception) { if (e.message?.contains("getContentDescription") == true) { return@runBlocking executeJS(js) } else { LOGGER.error("Failed to execute JS", e) } return@runBlocking null } } } private fun scrollToPoint(point: Point): Long { ensureOpen() val windowHeight = executeJS("window.innerHeight") as Int if (point.y >= 0 && point.y.toLong() <= windowHeight) return 0L val scrolledPixels = executeJS("() => {const delta = ${point.y} - Math.floor(window.innerHeight / 2); window.scrollBy({ top: delta, left: 0, behavior: 'smooth' }); return delta}()") as Int sleep(3000L) return scrolledPixels.toLong() } private fun sleep(ms: Long) { Thread.sleep(ms) } private fun scroll(top: String, left: String) { executeJS("window.scroll({ top: $top, left: $left, behavior: 'smooth' })") } private fun random(start: Int, end: Int): Int { return Random().nextInt((end + 1) - start) + start } override fun close() { injectedArguments = emptyMap() try { seleniumDriver?.quit() webScreenRecorder?.close() } catch (e: Exception) { // Swallow the exception to avoid crashing the whole process } seleniumDriver = null lastSeenWindowHandles = setOf() webScreenRecorder = null } override fun deviceInfo(): DeviceInfo { val width = executeJS("window.innerWidth") as Int val height = executeJS("window.innerHeight") as Int return DeviceInfo( platform = Platform.WEB, widthPixels = width, heightPixels = height, widthGrid = width, heightGrid = height, ) } override fun launchApp( appId: String, launchArguments: Map, ) { injectedArguments = injectedArguments + launchArguments runBlocking { val target = cdpClient.listTargets().first() cdpClient.openUrl(appId, target) } } override fun stopApp(appId: String) { // Not supported at the moment. // Simply calling driver.close() can kill the Selenium session, rendering // the driver inoperable. } override fun killApp(appId: String) { // On Web there is no Process Death like on Android so this command will be a synonym to the stop command stopApp(appId) } override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode { ensureOpen() detectWindowChange() // retrieve view hierarchy from DOM // There are edge cases where executeJS returns null, and we cannot get the hierarchy. In this situation // we retry multiple times until throwing an error eventually. (See issue #1936) var contentDesc: Any? = null var retry = 0 while (contentDesc == null) { contentDesc = executeJS("window.maestro.getContentDescription()") if (contentDesc == null) { retry++ } if (retry == RETRY_FETCHING_CONTENT_DESCRIPTION) { throw IllegalStateException("Could not retrieve hierarchy through maestro.getContentDescription() (tried $retry times") } } val rawMap = contentDesc as Map val enrichedMap = injectCrossOriginIframes(rawMap) val root = parseDomAsTreeNodes(enrichedMap) seleniumDriver?.currentUrl?.let { url -> root.attributes["url"] = url } return root } fun parseDomAsTreeNodes(domRepresentation: Map): TreeNode { val attrs = domRepresentation["attributes"] as Map val attributes = mutableMapOf( "text" to attrs["text"] as String, "bounds" to attrs["bounds"] as String, ) if (attrs.containsKey("resource-id") && attrs["resource-id"] != null) { attributes["resource-id"] = attrs["resource-id"] as String } if (attrs.containsKey("selected") && attrs["selected"] != null) { attributes["selected"] = (attrs["selected"] as Boolean).toString() } if (attrs.containsKey("synthetic") && attrs["synthetic"] != null) { attributes["synthetic"] = (attrs["synthetic"] as Boolean).toString() } if (attrs.containsKey("ignoreBoundsFiltering") && attrs["ignoreBoundsFiltering"] != null) { attributes["ignoreBoundsFiltering"] = (attrs["ignoreBoundsFiltering"] as Boolean).toString() } val children = domRepresentation["children"] as List> return TreeNode(attributes = attributes, children = children.map { parseDomAsTreeNodes(it) }) } private fun detectWindowChange() { // Checks whether there are any new window handles available and, if so, switches Selenium driver focus to it val driver = ensureOpen() if (lastSeenWindowHandles != driver.windowHandles) { val newHandles = driver.windowHandles - lastSeenWindowHandles lastSeenWindowHandles = driver.windowHandles if (newHandles.isNotEmpty()) { val newHandle = newHandles.first() LOGGER.info("Detected a window change, switching to new window handle $newHandle") driver.switchTo().window(newHandle) webScreenRecorder?.onWindowChange() } } } override fun clearAppState(appId: String) { ensureOpen() val origin = try { val uri = URI(appId) if (uri.scheme.isNullOrBlank() || uri.host.isNullOrBlank()) { null } else if (uri.port == -1) { "${uri.scheme}://${uri.host}" } else { "${uri.scheme}://${uri.host}:${uri.port}" } } catch (e: Exception) { LOGGER.warn("Failed to parse origin from $appId", e) null } if (origin == null) { return } try { runBlocking { val target = cdpClient.listTargets().first() cdpClient.clearDataForOrigin(origin, "all", target) } } catch (e: Exception) { LOGGER.warn("Failed to clear browser data for $origin", e) } } override fun clearKeychain() { // Do nothing } override fun tap(point: Point) { val driver = ensureOpen() if (point.x >= SYNTHETIC_COORDINATE_SPACE_OFFSET && point.y >= SYNTHETIC_COORDINATE_SPACE_OFFSET) { tapOnSyntheticCoordinateSpace(point) return } val pixelsScrolled = scrollToPoint(point) val mouse = PointerInput(PointerInput.Kind.MOUSE, "default mouse") val actions = Sequence(mouse, 1) .addAction( mouse.createPointerMove( Duration.ofMillis(400), PointerInput.Origin.viewport(), point.x, point.y - pixelsScrolled.toInt() ) ) (driver as RemoteWebDriver).perform(listOf(actions)) Actions(driver).click().build().perform() } private fun tapOnSyntheticCoordinateSpace(point: Point) { val elements = contentDescriptor() val hit = ViewHierarchy.from(this, true) .getElementAt(elements, point.x, point.y) if (hit == null) { return } if (hit.attributes["synthetic"] != "true") { return } executeJS("window.maestro.tapOnSyntheticElement(${point.x}, ${point.y})") } override fun longPress(point: Point) { val driver = ensureOpen() val mouse = PointerInput(PointerInput.Kind.MOUSE, "default mouse") val actions = Sequence(mouse, 0) .addAction(mouse.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), point.x, point.y)) (driver as RemoteWebDriver).perform(listOf(actions)) Actions(driver).clickAndHold().pause(3000L).release().build().perform() } override fun pressKey(code: KeyCode) { val key = mapToSeleniumKey(code) withActiveElement { it.sendKeys(key) } } private fun mapToSeleniumKey(code: KeyCode): Keys { return when (code) { KeyCode.ENTER -> Keys.ENTER KeyCode.BACKSPACE -> Keys.BACK_SPACE else -> error("Keycode $code is not supported on web") } } override fun scrollVertical() { // Check if this is a Flutter web app val isFlutter = executeJS("window.maestro.isFlutterApp()") as? Boolean ?: false if (isFlutter) { // Use Flutter-specific smooth animated scrolling executeJS("window.maestro.smoothScrollFlutter('UP', 500)") } else { // Use standard scroll for regular web pages scroll("window.scrollY + Math.round(window.innerHeight / 2)", "window.scrollX") } } override fun isKeyboardVisible(): Boolean { return false } override fun swipe(start: Point, end: Point, durationMs: Long) { val driver = ensureOpen() val finger = PointerInput(PointerInput.Kind.TOUCH, "finger") val swipe = Sequence(finger, 1) swipe.addAction( finger.createPointerMove( Duration.ofMillis(0), PointerInput.Origin.viewport(), start.x, start.y ) ) swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg())) swipe.addAction( finger.createPointerMove( Duration.ofMillis(durationMs), PointerInput.Origin.viewport(), end.x, end.y ) ) swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg())) (driver as RemoteWebDriver).perform(listOf(swipe)) } override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) { val isFlutter = executeJS("window.maestro.isFlutterApp()") as? Boolean ?: false if (isFlutter) { // Flutter web: Use smooth animated scrolling with easing executeJS("window.maestro.smoothScrollFlutter('${swipeDirection.name}', $durationMs)") } else { // HTML web: Use standard window scrolling when (swipeDirection) { SwipeDirection.UP -> scroll("window.scrollY + Math.round(window.innerHeight / 2)", "window.scrollX") SwipeDirection.DOWN -> scroll("window.scrollY - Math.round(window.innerHeight / 2)", "window.scrollX") SwipeDirection.LEFT -> scroll("window.scrollY", "window.scrollX + Math.round(window.innerWidth / 2)") SwipeDirection.RIGHT -> scroll("window.scrollY", "window.scrollX - Math.round(window.innerWidth / 2)") } } } override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) { // Ignoring elementPoint to enable a rudimentary implementation of scrollUntilVisible for web swipe(direction, durationMs) } override fun backPress() { val driver = ensureOpen() driver.navigate().back() } override fun inputText(text: String) { withActiveElement { element -> for (c in text.toCharArray()) { element.sendKeys("$c") sleep(random(20, 100).toLong()) } } } override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) { val driver = ensureOpen() driver.get(if (link.startsWith("http")) link else "https://$link") } override fun hideKeyboard() { // no-op on web return } override fun takeScreenshot(out: Sink, compressed: Boolean) { runBlocking { val target = cdpClient.listTargets().first() val bytes = cdpClient.captureScreenshot(target) out.buffer().use { it.write(bytes) } } } override fun startScreenRecording(out: Sink): ScreenRecording { val driver = ensureOpen() webScreenRecorder = WebScreenRecorder( JcodecVideoEncoder(), driver ) webScreenRecorder?.startScreenRecording(out) return object : ScreenRecording { override fun close() { webScreenRecorder?.close() } } } override fun setLocation(latitude: Double, longitude: Double) { val driver = ensureOpen() as HasDevTools driver.devTools.createSessionIfThereIsNotOne() driver.devTools.send( Emulation.setGeolocationOverride( Optional.of(latitude), Optional.of(longitude), Optional.of(0.0), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty() ) ) } override fun setOrientation(orientation: DeviceOrientation) { // No op } override fun eraseText(charactersToErase: Int) { withActiveElement { element -> for (i in 0 until charactersToErase) { element.sendKeys(Keys.BACK_SPACE) sleep(random(20, 50).toLong()) } } sleep(1000) } override fun setProxy(host: String, port: Int) { // Do nothing } override fun resetProxy() { // Do nothing } override fun isShutdown(): Boolean { close() return true } override fun isUnicodeInputSupported(): Boolean { return true } override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy { return ScreenshotUtils.waitForAppToSettle(initialHierarchy, this) } override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean { return ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this) } override fun capabilities(): List { return listOf( Capability.FAST_HIERARCHY ) } override fun setPermissions(appId: String, permissions: Map) { // no-op for web } override fun addMedia(mediaFiles: List) { // noop for web } override fun isAirplaneModeEnabled(): Boolean { return false } override fun setAirplaneMode(enabled: Boolean) { // Do nothing } override fun queryOnDeviceElements(query: OnDeviceElementQuery): List { return when (query) { is OnDeviceElementQuery.Css -> queryCss(query) else -> super.queryOnDeviceElements(query) } } private fun queryCss(query: OnDeviceElementQuery.Css): List { ensureOpen() val jsResult: Any? = executeJS("window.maestro.queryCss('${query.css}')") if (jsResult == null) { return emptyList() } if (jsResult is List<*>) { return jsResult .mapNotNull { it as? Map<*, *> } .map { parseDomAsTreeNodes(it as Map) } } else { LOGGER.error("Unexpected result type from queryCss: ${jsResult.javaClass.name}") return emptyList() } } @Suppress("UNCHECKED_CAST") private fun injectCrossOriginIframes(node: Map): Map { val attrs = node["attributes"] as Map val iframeSrc = attrs["__crossOriginIframe"] as? String if (iframeSrc != null) { val iframeContent = fetchCrossOriginIframeContent(iframeSrc) if (iframeContent != null) return iframeContent val cleanAttrs = attrs - "__crossOriginIframe" return mapOf("attributes" to cleanAttrs, "children" to emptyList()) } val children = (node["children"] as List>) .map { injectCrossOriginIframes(it) } return mapOf("attributes" to attrs, "children" to children) } @Suppress("UNCHECKED_CAST") private fun fetchCrossOriginIframeContent(iframeSrc: String): Map? { val driver = seleniumDriver ?: return null val jsExecutor = driver as? JavascriptExecutor ?: return null // Find the iframe element by its resolved src property (absolute URL) val iframeElement = try { jsExecutor.executeScript( "return [...document.querySelectorAll('iframe')].find(f => f.src === arguments[0]);", iframeSrc ) as? WebElement } catch (e: Exception) { LOGGER.warn("Could not find iframe element with src $iframeSrc", e) return null } ?: run { LOGGER.warn("No iframe element found with src $iframeSrc") return null } // Get the iframe's scaled viewport params (accounts for parent viewportWidth/Height scaling) val paramsJson = try { jsExecutor.executeScript( "return JSON.stringify(window.maestro.getIframeViewportParams(arguments[0]));", iframeSrc ) as? String } catch (e: Exception) { LOGGER.warn("Could not get viewport params for iframe $iframeSrc", e) return null } ?: return null val params = jacksonObjectMapper().readValue(paramsJson, Map::class.java) as Map val iframeX = (params["viewportX"] as? Number)?.toDouble() ?: 0.0 val iframeY = (params["viewportY"] as? Number)?.toDouble() ?: 0.0 val iframeW = (params["viewportWidth"] as? Number)?.toDouble() ?: 0.0 val iframeH = (params["viewportHeight"] as? Number)?.toDouble() ?: 0.0 // ChromeDriver can execute scripts inside cross-origin iframes via switchTo().frame() driver.switchTo().frame(iframeElement) return try { val resultJson = jsExecutor.executeScript(""" $maestroWebScript window.maestro.viewportX = $iframeX; window.maestro.viewportY = $iframeY; window.maestro.viewportWidth = $iframeW; window.maestro.viewportHeight = $iframeH; return JSON.stringify(window.maestro.getContentDescription()); """.trimIndent()) as? String ?: return null jacksonObjectMapper().readValue(resultJson, Map::class.java) as? Map } catch (e: Exception) { LOGGER.warn("Failed to get content description from cross-origin iframe $iframeSrc", e) null } finally { try { driver.switchTo().defaultContent() } catch (e: Exception) { LOGGER.warn("Failed to switch back to default content", e) } } } /** * Locates the truly focused element, even when it lives inside a cross-origin iframe. * * When the user taps inside a cross-origin iframe the main frame's * `document.activeElement` is the `