fastlane-plugin-maestro_orchestration 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 16bca8a824864e18e5022ff562fb194076093be36b157665c8a9b35badbe047e
4
+ data.tar.gz: 32fe2d534c4d295245009d6672750f748adfdfce4dcd6ca8864a60e989d19936
5
+ SHA512:
6
+ metadata.gz: 8f5c55f53f32f13711091ebb0e989b7475b133e64739ff839966a62239eef3fd8a53d555ee1c64da888c1ba810bed487b9316f61786f56cc4d8564fdcfe03a5c
7
+ data.tar.gz: e923fa4cae04e21f78111f8d1dc0083b88a616f3c60a5d1570750087349014608eff5926dec3cc9d9cb734fa7963b13e46cbec082ff4022149e20f4a373bdb0b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Nemanja Risteski <nemanja.risteski@sourcetoad.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # maestro_orchestration plugin
2
+
3
+ [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-maestro_orchestration)
4
+
5
+ ## Getting Started
6
+
7
+ This project is a [_fastlane_](https://github.com/fastlane/fastlane) plugin. To get started with `fastlane-plugin-maestro_orchestration`, add it to your project by running:
8
+
9
+ ```bash
10
+ fastlane add_plugin maestro_orchestration
11
+ ```
12
+
13
+ ## About maestro_orchestration
14
+
15
+ The `maestro_orchestration` plugin enhances your Fastlane workflows by integrating with the Maestro testing framework. It provides the following actions:
16
+
17
+ ### 1. `maestro_orchestration` - separate actions for iOS and Android platform.
18
+ Executes Maestro test suites within your Fastlane lanes, facilitating automated UI testing for mobile applications.
19
+
20
+ ## Parameters `iOS`
21
+
22
+ | Parameter | Env Name | Notes |
23
+ | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `workspace` | `MAESTRO_IOS_WORKSPACE` | Path to the project's Xcode workspace directory. <br> **Required** |
25
+ | `scheme` | `MAESTRO_IOS_SCHEME` | The iOS app scheme to build. <br> **Required** |
26
+ | `maestro_flow_file` | `MAESTRO_IOS_FLOW_FILE` | The path to the Maestro flows YAML file. <br> **Required** |
27
+ | `simulator_name` | `MAESTRO_IOS_DEVICE_NAME` | The iOS simulator device to boot. <br> **Default value:** 'iPhone 15' |
28
+ | `device_type` | `MAESTRO_IOS_DEVICE` | The iOS simulator device type for new simulator (e.g., iPhone #, iPad, etc...). <br> **Default value:** 'com.apple.CoreSimulator.SimDeviceType.iPhone-15'|
29
+
30
+ ## Parameters `Android`
31
+
32
+ | Parameter | Env Name | Notes |
33
+ | ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `sdk_dir` | `MAESTRO_ANDROID_SDK_DIR` | Path to the Android SDK DIR. <br> **Required** `ENV["ANDROID_HOME"]`, `ENV["ANDROID_SDK_ROOT"]`, `~/Library/Android/sdk` |
35
+ | `maestro_flow_file` | `MAESTRO_IOS_FLOW_FILE` | The path to the Maestro flows YAML file. <br> **Required** |
36
+ | `emulator_name` | `MAESTRO_AVD_NAME` | Name of the AVD. <br> **Default value:** 'Maestro\_Android\_Emulator' |
37
+ | `emulator_package` | `MAESTRO_AVD_PACKAGE` | The selected system image of the emulator. <br> **Default value:** 'system-images;android-35;google_apis_playstore;arm64-v8a' |
38
+ | `emulator_device` | `MAESTRO_AVD_DEVICE` | Type of android device. <br> **Default value:** 'pixel_7_pro' |
39
+ | `emulator_port` | `MAESTRO_AVD_PORT` | Port of the emulator. <br> **Default value:** 5554 |
40
+
41
+ ### 2. `maestro_orchestartion_s3_upload`
42
+ Uploads a folder of files (such as screenshots) to an S3 bucket, organizing them based on the app version, theme, and device type.
43
+
44
+ | Parameter | Env Name | Notes |
45
+ | ------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
46
+ | `folder_path` | `MAESTRO_SCREENSHOTS_FOLDER_PATH` | Path to the local folder containing the files to upload. <br> **Required** |
47
+ | `bucket` | `MAESTRO_SCREENSHOTS_S3_BUCKET` | The name of the S3 bucket where files will be uploaded. <br> **Required** |
48
+ | `s3_path` | `MAESTRO_SCREENSHOTS_S3_PATH` | The base S3 path (excluding the bucket name). <br> **Required** |
49
+ | `version` | `MAESTRO_SCREENSHOTS_APP_VERSION` | The app version associated with the uploaded files. <br> **Required** |
50
+ | `device` | `MAESTRO_SCREENSHOTS_DEVICE` | The target device type (android or ios). <br> **Required** |
51
+ | `theme` | `MAESTRO_SCREENSHOTS_APPLICATION_THEME` | The application theme (e.g., dark or light). <br> Optional |
52
+
53
+ ### 3. `maestro_orchestration_api_request`
54
+ Sends an API request with a signed payload, typically used to notify external systems of events such as the completion of test runs or the availability of new screenshots.
55
+
56
+ | Parameter | Env Name | Notes |
57
+ | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
58
+ | `s3_path` | `MAESTRO_SCREENSHOTS_S3_PATH` | The base S3 path (excluding the bucket name) where files are uploaded. <br> **Required** |
59
+ | `version` | `MAESTRO_SCREENSHOTS_APP_VERSION` | The version of the app associated with the screenshots or test results. <br> **Required** |
60
+ | `device` | `MAESTRO_SCREENSHOTS_APP_VERSION` | The device type (android or ios). <br> **Required** |
61
+ | `theme` | `MAESTRO_SCREENSHOTS_APPLICATION_THEME` | The application theme (e.g., dark or light). <br> Optional |
62
+ | `hmac_secret` | `MAESTRO_SCREENSHOTS_HMAC_SECRET` | The HMAC secret used to sign the payload for security purposes. <br> **Required** |
63
+ | `url` | `MAESTRO_SCREENSHOTS_WEBHOOK_URL` | The endpoint URL to which the API request is sent. <br> **Required** |
64
+
65
+ ## Example
66
+
67
+ **iOS**
68
+ ```ruby
69
+ lane :maestro do |options|
70
+ maestro_orchestration_ios(
71
+ scheme: your_app,
72
+ workspace: your_app.xcworskapce,
73
+ maestro_flow_file: "../.maestro/flow_ios.yaml"
74
+ )
75
+
76
+ maestro_orchestration_s3_upload(
77
+ folder_path: "../.maestro/android/screenshots,
78
+ bucket: "your-s3-bucket-name",
79
+ s3_path: "path/to/s3/folder",
80
+ version: "1.0.0",
81
+ device: "android",
82
+ theme: "dark" # optional
83
+ )
84
+
85
+ maestro_orchestration_api_request(
86
+ s3_path: "path/to/s3/folder",
87
+ version: "1.0.0",
88
+ device: "android",
89
+ hmac_secret: "your-hmac-secret",
90
+ url: "https://your-webhook-url.com"
91
+ )
92
+ end
93
+ ```
94
+ **Android**
95
+ ```ruby
96
+ lane :maestro do |options|
97
+ maestro_orchestration_android(
98
+ maestro_flow_file: "../.maestro/flow_android.yaml
99
+ )
100
+
101
+ maestro_orchestration_s3_upload(
102
+ folder_path: "../.maestro/android/screenshots,
103
+ bucket: "your-s3-bucket-name",
104
+ s3_path: "path/to/s3/folder",
105
+ version: "1.0.0",
106
+ device: "android",
107
+ theme: "dark" # optional
108
+ )
109
+
110
+ maestro_orchestration_api_request(
111
+ s3_path: "path/to/s3/folder",
112
+ version: "1.0.0",
113
+ device: "android",
114
+ hmac_secret: "your-hmac-secret",
115
+ url: "https://your-webhook-url.com"
116
+ )
117
+ end
118
+ ```
119
+ **Note:** For Android platform, the plugin relies on the already previously generated build by Fastlane instead of generating a new one like for the iOS. The plugin was intended to run on simulators, and iOS has differents build types for simulators and real devices.
120
+
121
+ ## Run tests for this plugin
122
+
123
+ To run both the tests, and code style validation, run
124
+
125
+ ```
126
+ rake
127
+ ```
128
+
129
+ To automatically fix many of the styling issues, use
130
+ ```
131
+ rubocop -a
132
+ ```
133
+
134
+ ## Issues and Feedback
135
+
136
+ For any other issues and feedback about this plugin, please submit it to this repository.
137
+
138
+ ## Troubleshooting
139
+
140
+ If you have trouble using plugins, check out the [Plugins Troubleshooting](https://docs.fastlane.tools/plugins/plugins-troubleshooting/) guide.
141
+
142
+ ## Using _fastlane_ Plugins
143
+
144
+ For more information about how the `fastlane` plugin system works, check out the [Plugins documentation](https://docs.fastlane.tools/plugins/create-plugin/).
145
+
146
+ ## About _fastlane_
147
+
148
+ _fastlane_ is the easiest way to automate beta deployments and releases for your iOS and Android apps. To learn more, check out [fastlane.tools](https://fastlane.tools).
@@ -0,0 +1,197 @@
1
+ require 'fastlane/action'
2
+ require 'fastlane_core/configuration/config_item'
3
+ require 'fastlane/helper/adb_helper'
4
+ require_relative '../helper/maestro_orchestration_helper'
5
+
6
+ module Fastlane
7
+ module Actions
8
+ class MaestroOrchestrationAndroidAction < Action
9
+ def self.run(params)
10
+ required_params = [:emulator_package, :emulator_device, :maestro_flow_file]
11
+ missing_params = required_params.select { |param| params[param].nil? }
12
+
13
+ if missing_params.any?
14
+ missing_params.each do |param|
15
+ UI.error("Missing parameter: #{param}")
16
+ end
17
+ raise "Missing required parameters: #{missing_params.join(', ')}"
18
+ end
19
+
20
+ UI.message("--------------\n\nSDK DIR: #{params[:sdk_dir]}\n\n--------------")
21
+ adb = Helper::AdbHelper.new
22
+
23
+ setup_emulator(params)
24
+ sleep(5)
25
+ demo_mode(params)
26
+ install_android_app(params)
27
+
28
+ UI.message("Running Maestro tests on Android...")
29
+ devices = adb.load_all_devices
30
+ if devices.empty?
31
+ UI.message("No running emulators found.")
32
+ else
33
+ sleep(2)
34
+ UI.message("Devices: #{devices}")
35
+ devices.each do |device|
36
+ sh("maestro --device #{device.serial} test #{params[:maestro_flow_file]}")
37
+ UI.success("Finished Maestro tests on Android.")
38
+ end
39
+ end
40
+
41
+ UI.message("Exit demo mode and kill Android emulator...")
42
+ adb.trigger(command: "shell am broadcast -a com.android.systemui.demo -e command exit", serial: devices.first.serial)
43
+ sleep(5)
44
+ adb.trigger(command: "emu kill", serial: devices.first.serial)
45
+ UI.success("Android emulator killed. Process finished.")
46
+ end
47
+
48
+ def self.setup_emulator(params)
49
+ emulator = Helper::EmulatorHelper.new
50
+ adb = Helper::AdbHelper.new
51
+ avdmanager = Helper::AvdHelper.new
52
+
53
+ UI.message("Stop all running emulators...")
54
+ devices = adb.load_all_devices
55
+ UI.success("Devices: #{devices}")
56
+
57
+ if devices.empty?
58
+ UI.message("No running emulators found.")
59
+ else
60
+ devices.each do |device|
61
+ UI.message("Stopping emulator: #{device.serial}")
62
+ adb.trigger(command: "emu kill", serial: device.serial)
63
+ sleep(10)
64
+ system("Stopped emulator: #{device.serial}")
65
+ end
66
+ end
67
+ UI.message("Waiting for all emulators to stop...")
68
+ sleep(10)
69
+
70
+ UI.message("Setting up new Android emulator...")
71
+ avdmanager.create_avd(name: params[:emulator_name], package: params[:emulator_package], device: params[:emulator_device])
72
+
73
+ UI.message("Starting Android emulator...")
74
+ emulator.start_emulator(name: params[:emulator_name], port: params[:emulator_port])
75
+ adb.trigger(command: "wait-for-device", serial: "emulator-#{params[:emulator_port]}")
76
+
77
+ max_retries = 10
78
+ booted = Helper::MaestroOrchestrationHelper.wait_for_emulator_to_boot(adb, max_retries, "emulator-#{params[:emulator_port]}")
79
+
80
+ unless booted
81
+ UI.error("Emulator failed to boot after #{max_retries} attempts. Restarting ADB server...")
82
+ adb.trigger(command: "kill-server")
83
+ adb.trigger(command: "start-server")
84
+ UI.message("ADB server restarted. Retrying boot process...")
85
+
86
+ # Retry boot process after restarting ADB server
87
+ booted = Helper::MaestroOrchestrationHelper.wait_for_emulator_to_boot(adb, max_retries, "emulator-#{params[:emulator_port]}")
88
+ end
89
+
90
+ if booted
91
+ UI.success("Emulator is online and fully booted!")
92
+ else
93
+ UI.error("Emulator failed to boot even after restarting ADB server.")
94
+ raise "Failed to boot emulator. Please check emulator logs and configuration."
95
+ end
96
+ end
97
+
98
+ def self.demo_mode(params)
99
+ adb = Helper::AdbHelper.new
100
+ adb.load_all_devices
101
+ serial = adb.devices.first.serial
102
+
103
+ UI.message("Checking and allowing demo mode on Android emulator...")
104
+ adb.trigger(command: "shell settings put global sysui_demo_allowed 1", serial: serial)
105
+ adb.trigger(command: "shell settings get global sysui_demo_allowed", serial: serial)
106
+
107
+ UI.message("Setting demo mode commands...")
108
+ adb.trigger(command: "shell am broadcast -a com.android.systemui.demo -e command enter", serial: serial)
109
+ adb.trigger(command: "shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1200", serial: serial)
110
+ adb.trigger(command: "shell am broadcast -a com.android.systemui.demo -e command battery -e level 100", serial: serial)
111
+ end
112
+
113
+ def self.install_android_app(params)
114
+ UI.message("Installing Android app...")
115
+
116
+ adb = Helper::AdbHelper.new
117
+ adb.load_all_devices
118
+ serial = adb.devices.first.serial
119
+
120
+ apk_path = Dir["app/build/outputs/apk/release/*.apk"].first
121
+
122
+ if apk_path.nil?
123
+ UI.user_error!("Error: APK file not found in build outputs.")
124
+ end
125
+
126
+ UI.message("Found APK file at: #{apk_path}")
127
+ adb.trigger(command: "install -r '#{apk_path}'", serial: serial)
128
+ UI.success("APK installed on Android emulator.")
129
+ end
130
+
131
+ def self.description
132
+ "Boots an Android emulator, builds the app, installs it, and runs Maestro tests"
133
+ end
134
+
135
+ def self.available_options
136
+ [
137
+ FastlaneCore::ConfigItem.new(
138
+ key: :sdk_dir,
139
+ env_name: "MAESTRO_ANDROID_SDK_DIR",
140
+ description: "Path to the Android SDK DIR",
141
+ default_value: ENV["ANDROID_HOME"] || ENV["ANDROID_SDK_ROOT"] || "~/Library/Android/sdk",
142
+ optional: true,
143
+ verify_block: proc do |value|
144
+ UI.user_error!("No ANDROID_SDK_DIR given, pass using `sdk_dir: 'sdk_dir'`") unless value && !value.empty?
145
+ end
146
+ ),
147
+ FastlaneCore::ConfigItem.new(
148
+ key: :emulator_name,
149
+ env_name: "MAESTRO_AVD_NAME",
150
+ description: "Name of the AVD",
151
+ default_value: "Maestro_Android_Emulator",
152
+ optional: true
153
+ ),
154
+ FastlaneCore::ConfigItem.new(
155
+ key: :emulator_package,
156
+ env_name: "MAESTRO_AVD_PACKAGE",
157
+ description: "The selected system image of the emulator",
158
+ default_value: "system-images;android-35;google_apis_playstore;arm64-v8a",
159
+ optional: true
160
+ ),
161
+ FastlaneCore::ConfigItem.new(
162
+ key: :emulator_device,
163
+ env_name: "MAESTRO_AVD_DEVICE",
164
+ description: "Device",
165
+ default_value: "pixel_7_pro",
166
+ optional: true
167
+ ),
168
+ FastlaneCore::ConfigItem.new(
169
+ key: :location,
170
+ env_name: "MAESTRO_AVD_LOCATION",
171
+ description: "Set location of the emulator '<longitude> <latitude>'",
172
+ default_value: "28.0362979, -82.4930012",
173
+ optional: true
174
+ ),
175
+ FastlaneCore::ConfigItem.new(
176
+ key: :emulator_port,
177
+ env_name: "MAESTRO_AVD_PORT",
178
+ description: "Port of the emulator",
179
+ default_value: "5554",
180
+ optional: true
181
+ ),
182
+ FastlaneCore::ConfigItem.new(
183
+ key: :maestro_flow_file,
184
+ env_name: "MAESTRO_ANDROID_FLOW_FILE",
185
+ description: "The path to the Maestro flow YAML file",
186
+ optional: false,
187
+ type: String
188
+ )
189
+ ]
190
+ end
191
+
192
+ def self.is_supported?(platform)
193
+ platform == :android
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,144 @@
1
+ require 'fastlane/action'
2
+ require 'openssl'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'fastlane_core/configuration/config_item'
7
+ require_relative '../helper/maestro_orchestration_helper'
8
+
9
+ module Fastlane
10
+ module Actions
11
+ class MaestroOrchestrationApiRequestAction < Action
12
+ def self.run(params)
13
+ required_params = [:s3_path, :version, :device, :hmac_secret, :url]
14
+ missing_params = required_params.select { |param| params[param].nil? }
15
+
16
+ if missing_params.any?
17
+ missing_params.each do |param|
18
+ UI.error("Missing parameter: #{param}")
19
+ end
20
+ raise "Missing required parameters: #{missing_params.join(', ')}"
21
+ end
22
+
23
+ base_path = "#{params[:s3_path]}/ver:#{params[:version]}"
24
+ base_path += "/theme:#{params[:theme]}" if params[:theme]
25
+ base_path += "/device:#{params[:device]}"
26
+
27
+ payload = {
28
+ message: "Screenshots uploaded",
29
+ version: params[:version],
30
+ folder_path: base_path
31
+ }
32
+ payload_json = payload.to_json
33
+
34
+ signature = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', params[:hmac_secret], payload_json)}"
35
+
36
+ uri = URI.parse(params[:url])
37
+ http = Net::HTTP.new(uri.host, uri.port)
38
+ http.use_ssl = (uri.scheme == "https")
39
+
40
+ request = Net::HTTP::Post.new(uri.path, {
41
+ "Content-Type" => "application/json",
42
+ "X-Action-Signature" => signature
43
+ })
44
+ request.body = payload_json
45
+
46
+ response = http.request(request)
47
+
48
+ if response.kind_of?(Net::HTTPSuccess)
49
+ UI.success("API request successful: #{response.code} - #{response.body}")
50
+ else
51
+ UI.user_error!("API request failed: #{response.code} - #{response.body}")
52
+ end
53
+ end
54
+
55
+ def self.description
56
+ "Sends an API request with a signed payload."
57
+ end
58
+
59
+ def self.available_options
60
+ [
61
+ s3_path_option,
62
+ version_option,
63
+ device_option,
64
+ theme_option,
65
+ hmac_secret_option,
66
+ url_option
67
+ ]
68
+ end
69
+
70
+ def self.s3_path_option
71
+ FastlaneCore::ConfigItem.new(
72
+ key: :s3_path,
73
+ env_name: "MAESTRO_SCREENSHOTS_S3_PATH",
74
+ description: "The base S3 path (after the bucket name) where files will be uploaded: $bucket/$s3_path",
75
+ optional: false
76
+ )
77
+ end
78
+
79
+ def self.version_option
80
+ FastlaneCore::ConfigItem.new(
81
+ key: :version,
82
+ env_name: "MAESTRO_SCREENSHOTS_APP_VERSION",
83
+ description: "Version of the app that screenshots are taken from",
84
+ optional: false,
85
+ verify_block: proc do |value|
86
+ UI.user_error!("You must provide a version using the `version` parameter.") unless value && !value.strip.empty?
87
+ end
88
+ )
89
+ end
90
+
91
+ def self.device_option
92
+ FastlaneCore::ConfigItem.new(
93
+ key: :device,
94
+ env_name: "MAESTRO_SCREENSHOTS_DEVICE",
95
+ description: "Device type: android or ios",
96
+ type: String,
97
+ optional: false,
98
+ verify_block: proc do |value|
99
+ UI.user_error!("You must specify a device type (android or ios).") unless %w[android ios].include?(value.downcase)
100
+ end
101
+ )
102
+ end
103
+
104
+ def self.theme_option
105
+ FastlaneCore::ConfigItem.new(
106
+ key: :theme,
107
+ env_name: "MAESTRO_SCREENSHOTS_APPLICATION_THEME",
108
+ description: "Optional theme parameter (e.g., dark or light)",
109
+ default_value: nil,
110
+ optional: true
111
+ )
112
+ end
113
+
114
+ def self.hmac_secret_option
115
+ FastlaneCore::ConfigItem.new(
116
+ key: :hmac_secret,
117
+ env_name: "MAESTRO_SCREENSHOTS_HMAC_SECRET",
118
+ description: "The HMAC secret used to sign the payload",
119
+ optional: false,
120
+ verify_block: proc do |value|
121
+ UI.user_error!("You must provide a valid HMAC secret using the `hmac_secret` parameter.") unless value && !value.strip.empty?
122
+ end
123
+ )
124
+ end
125
+
126
+ def self.url_option
127
+ FastlaneCore::ConfigItem.new(
128
+ key: :url,
129
+ env_name: "MAESTRO_SCREENSHOTS_WEBHOOK_URL",
130
+ description: "The URL to send the API request to",
131
+ optional: false,
132
+ verify_block: proc do |value|
133
+ UI.user_error!("You must provide a valid URL using the `url` parameter.") unless value && !value.strip.empty?
134
+ UI.user_error!("The provided URL is invalid: #{value}") unless value.match?(URI::DEFAULT_PARSER.make_regexp)
135
+ end
136
+ )
137
+ end
138
+
139
+ def self.is_supported?(platform)
140
+ true
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,166 @@
1
+ require 'fastlane/action'
2
+ require 'fastlane_core/configuration/config_item'
3
+ require_relative '../helper/maestro_orchestration_helper'
4
+
5
+ module Fastlane
6
+ module Actions
7
+ class MaestroOrchestrationIosAction < Action
8
+ def self.run(params)
9
+ required_params = [:scheme, :workspace, :maestro_flow_file]
10
+ missing_params = required_params.select { |param| params[param].nil? }
11
+
12
+ if missing_params.any?
13
+ missing_params.each do |param|
14
+ UI.error("Missing parameter: #{param}")
15
+ end
16
+ raise "Missing required parameters: #{missing_params.join(', ')}"
17
+ end
18
+
19
+ boot_ios_simulator(params)
20
+ demo_mode(params)
21
+ build_and_install_ios_app(params)
22
+
23
+ UI.message("Running Maestro tests on iOS...")
24
+
25
+ simulators_list = `xcrun simctl list devices`.strip
26
+ device_status = simulators_list.match(/#{Regexp.quote(params[:simulator_name])}.*\(([^)]+)\) \(([^)]+)\)/)
27
+ device_id = device_status[1]
28
+ `maestro --device #{device_id} test #{params[:maestro_flow_file]}`
29
+ UI.success("Finished Maestro tests on iOS.")
30
+
31
+ UI.message("Killing iOS simulator...")
32
+ system("xcrun simctl shutdown booted")
33
+ UI.success("iOS simulator killed. Process finished.")
34
+ end
35
+
36
+ def self.boot_ios_simulator(params)
37
+ device_name = params[:simulator_name]
38
+ device_type = params[:device_type]
39
+
40
+ UI.message("Shutting down any booted iOS simulator...")
41
+ system("xcrun simctl shutdown booted")
42
+
43
+ UI.message("Checking if simulator '#{device_name}' exists...")
44
+ simulators_list = `xcrun simctl list devices -j`
45
+ simulator_data = JSON.parse(simulators_list)["devices"].values.flatten
46
+
47
+ existing_simulator = simulator_data.find { |sim| sim["name"] == device_name }
48
+
49
+ if existing_simulator
50
+ device_id = existing_simulator["udid"]
51
+ UI.message("Found existing simulator '#{device_name}' with ID #{device_id}. Deleting it...")
52
+ system("xcrun simctl delete #{device_id}")
53
+ end
54
+
55
+ UI.message("Creating a new simulator '#{device_name}'...")
56
+ system("xcrun simctl create '#{device_name}' #{device_type}")
57
+
58
+ # Refresh simulator list after creation
59
+ simulators_list = `xcrun simctl list devices -j`
60
+ new_simulator = JSON.parse(simulators_list)["devices"].values.flatten.find { |sim| sim["name"] == device_name }
61
+
62
+ unless new_simulator
63
+ UI.user_error!("Failed to create simulator '#{device_name}'.")
64
+ end
65
+
66
+ new_device_id = new_simulator["udid"]
67
+ UI.message("Booting the new simulator '#{device_name}' (ID: #{new_device_id})...")
68
+ system("xcrun simctl boot '#{new_device_id}'")
69
+
70
+ UI.message("Waiting for the simulator to fully boot...")
71
+ until `xcrun simctl list devices`.include?("#{device_name} (#{new_device_id}) (Booted)")
72
+ UI.message("Waiting for the simulator to boot...")
73
+ sleep(10)
74
+ end
75
+
76
+ UI.success("Simulator '#{device_name}' is booted and ready.")
77
+ end
78
+
79
+ def self.demo_mode(params)
80
+ UI.message("Setting demo mode on #{params[:simulator_name]}...")
81
+ sh("xcrun simctl status_bar '#{params[:simulator_name]}' override --time '09:30'")
82
+ sh("xcrun simctl status_bar '#{params[:simulator_name]}' override --batteryState charged --batteryLevel 100")
83
+ sh("xcrun simctl status_bar '#{params[:simulator_name]}' override --wifiBars 3 --cellularBars 4")
84
+ end
85
+
86
+ def self.build_and_install_ios_app(params)
87
+ UI.message("Building iOS app with scheme: #{params[:scheme]}")
88
+ other_action.gym(
89
+ workspace: params[:workspace],
90
+ scheme: params[:scheme],
91
+ destination: "platform=iOS Simulator,name=#{params[:simulator_name]}",
92
+ configuration: "Release",
93
+ clean: true,
94
+ sdk: "iphonesimulator",
95
+ build_path: "./build",
96
+ skip_archive: true,
97
+ skip_package_ipa: true,
98
+ include_symbols: false,
99
+ include_bitcode: false,
100
+ xcargs: "-UseModernBuildSystem=YES"
101
+ )
102
+
103
+ derived_data_path = File.expand_path("~/Library/Developer/Xcode/DerivedData")
104
+ app_path = Dir["#{derived_data_path}/**/Release-iphonesimulator/#{params[:scheme]}.app"].first
105
+
106
+ if app_path.nil?
107
+ UI.user_error!("Error: .app file not found in DerivedData.")
108
+ end
109
+
110
+ UI.message("Found .app file at: #{app_path}")
111
+ sh("xcrun simctl install booted '#{app_path}'")
112
+ UI.success("App installed on iOS simulator.")
113
+ end
114
+
115
+ def self.description
116
+ "Boots an iOS simulator, builds the app, installs it, and runs Maestro tests"
117
+ end
118
+
119
+ def self.available_options
120
+ [
121
+ FastlaneCore::ConfigItem.new(
122
+ key: :simulator_name,
123
+ env_name: "MAESTRO_IOS_DEVICE_NAME",
124
+ description: "The iOS simulator device to boot",
125
+ default_value: "iPhone 15",
126
+ optional: true,
127
+ type: String
128
+ ),
129
+ FastlaneCore::ConfigItem.new(
130
+ key: :device_type,
131
+ env_name: "MAESTRO_IOS_DEVICE",
132
+ description: "The iOS simulator device type for new simulator",
133
+ default_value: "com.apple.CoreSimulator.SimDeviceType.iPhone-15",
134
+ optional: true,
135
+ type: String
136
+ ),
137
+ FastlaneCore::ConfigItem.new(
138
+ key: :scheme,
139
+ env_name: "MAESTRO_IOS_SCHEME",
140
+ description: "The iOS app scheme to build",
141
+ optional: false,
142
+ type: String
143
+ ),
144
+ FastlaneCore::ConfigItem.new(
145
+ key: :workspace,
146
+ env_name: "MAESTRO_IOS_WORKSPACE",
147
+ description: "The Xcode workspace",
148
+ optional: false,
149
+ type: String
150
+ ),
151
+ FastlaneCore::ConfigItem.new(
152
+ key: :maestro_flow_file,
153
+ env_name: "MAESTRO_IOS_FLOW_FILE",
154
+ description: "The path to the Maestro flows YAML file",
155
+ optional: false,
156
+ type: String
157
+ )
158
+ ]
159
+ end
160
+
161
+ def self.is_supported?(platform)
162
+ platform == :ios
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,106 @@
1
+ require 'fastlane/action'
2
+ require 'aws-sdk-s3'
3
+ require 'fastlane_core/configuration/config_item'
4
+ require_relative '../helper/maestro_orchestration_helper'
5
+
6
+ module Fastlane
7
+ module Actions
8
+ class MaestroOrchestrationS3UploadAction < Action
9
+ def self.run(params)
10
+ required_params = [:folder_path, :bucket, :version, :device]
11
+ missing_params = required_params.select { |param| params[param].nil? }
12
+
13
+ if missing_params.any?
14
+ missing_params.each do |param|
15
+ UI.error("Missing parameter: #{param}")
16
+ end
17
+ raise "Missing required parameters: #{missing_params.join(', ')}"
18
+ end
19
+
20
+ UI.message("Uploading screenshots to S3...")
21
+
22
+ s3_client = Aws::S3::Client.new(
23
+ region: ENV.fetch('AWS_REGION', 'us-east-1')
24
+ )
25
+
26
+ base_path = "#{params[:s3_path]}/ver:#{params[:version]}"
27
+ base_path += "/theme:#{params[:theme]}" if params[:theme]
28
+ base_path += "/device:#{params[:device]}"
29
+
30
+ UI.message("Folder path: #{params[:folder_path]}")
31
+ Dir.glob("#{params[:folder_path]}/*").each do |file|
32
+ next if File.directory?(file)
33
+
34
+ file_name = File.basename(file)
35
+ s3_key = File.join(base_path, file_name)
36
+
37
+ UI.message("Uploading #{file} to s3://#{params[:bucket]}/#{s3_key}")
38
+ s3_client.put_object(bucket: params[:bucket], key: s3_key, body: File.open(file))
39
+ end
40
+
41
+ UI.success("Upload to S3 completed.")
42
+ end
43
+
44
+ def self.description
45
+ "Uploads files to an S3 bucket."
46
+ end
47
+
48
+ def self.available_options
49
+ [
50
+ FastlaneCore::ConfigItem.new(
51
+ key: :folder_path,
52
+ env_name: "MAESTRO_SCREENSHOTS_FOLDER_PATH",
53
+ description: "Path to the folder to be uploaded to S3",
54
+ optional: false,
55
+ verify_block: proc do |value|
56
+ UI.user_error!("You must provide a valid folder path using the `folder_path` parameter.") unless value && !value.strip.empty?
57
+ UI.user_error!("The folder path does not exist: #{value}") unless File.directory?(value)
58
+ end
59
+ ),
60
+ FastlaneCore::ConfigItem.new(
61
+ key: :bucket,
62
+ env_name: "MAESTRO_SCREENSHOTS_S3_BUCKET",
63
+ description: "The S3 bucket name where files will be uploaded",
64
+ optional: false,
65
+ verify_block: proc do |value|
66
+ UI.user_error!("You must provide a valid S3 bucket name using the `bucket` parameter.") unless value && !value.strip.empty?
67
+ end
68
+ ),
69
+ FastlaneCore::ConfigItem.new(
70
+ key: :s3_path,
71
+ env_name: "MAESTRO_SCREENSHOTS_S3_PATH",
72
+ description: "The base S3 path (after the bucket name) where files will be uploaded: $bucket/$s3_path",
73
+ optional: false
74
+ ),
75
+ FastlaneCore::ConfigItem.new(
76
+ key: :version,
77
+ env_name: "MAESTRO_SCREENSHOTS_APP_VERSION",
78
+ description: "Version of the app that screenshots are taken from",
79
+ optional: false,
80
+ verify_block: proc do |value|
81
+ UI.user_error!("You must provide a version using the `version` parameter.") unless value && !value.strip.empty?
82
+ end
83
+ ),
84
+ FastlaneCore::ConfigItem.new(
85
+ key: :device,
86
+ env_name: "MAESTRO_SCREENSHOTS_DEVICE",
87
+ description: "Device type: android or ios",
88
+ type: String,
89
+ optional: false
90
+ ),
91
+ FastlaneCore::ConfigItem.new(
92
+ key: :theme,
93
+ env_name: "MAESTRO_SCREENSHOTS_APPLICATION_THEME",
94
+ description: "Optional theme parameter (e.g., dark or light)",
95
+ default_value: nil,
96
+ optional: true
97
+ )
98
+ ]
99
+ end
100
+
101
+ def self.is_supported?(platform)
102
+ true
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,132 @@
1
+ require 'fastlane_core/ui/ui'
2
+ require 'fastlane/action'
3
+
4
+ module Fastlane
5
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
6
+ Helper = FastlaneCore::Helper unless Fastlane.const_defined?(:Helper)
7
+
8
+ module Helper
9
+ class MaestroOrchestrationHelper
10
+ # class methods that you define here become available in your action
11
+ # as `Helper::MaestroOrchestrationHelper.your_method`
12
+ #
13
+ def self.show_message
14
+ UI.message("Hello from the maestro_orchestration plugin helper!")
15
+ end
16
+
17
+ def self.wait_for_emulator_to_boot(adb, max_retries, serial)
18
+ retries = 0
19
+ booted = false
20
+
21
+ while retries < max_retries
22
+ result = `#{adb.adb_path} -e shell getprop sys.boot_completed`.strip
23
+ UI.message("ADB Response (sys.boot_completed): #{result.inspect}")
24
+
25
+ if result == "1"
26
+ booted = true
27
+ break
28
+ elsif result.empty? || result.include?("device offline") || result.include?("device unauthorized")
29
+ UI.error("ADB issue detected: #{result}")
30
+ end
31
+
32
+ retries += 1
33
+ UI.message("Retrying... Attempt #{retries}/#{max_retries}")
34
+
35
+ wait_interval = [1 + (2**retries), 30].min
36
+
37
+ UI.message("Waiting for #{wait_interval} seconds before retrying...")
38
+ sleep(wait_interval)
39
+ end
40
+
41
+ booted
42
+ end
43
+ end
44
+
45
+ class AvdHelper
46
+ # Path to the avd binary
47
+ attr_accessor :avdmanager_path
48
+ # Available AVDs
49
+ attr_accessor :avds
50
+
51
+ def initialize(avdmanager_path: nil)
52
+ android_home = ENV.fetch('ANDROID_HOME', nil) || ENV.fetch('ANDROID_SDK_ROOT', nil)
53
+ if (avdmanager_path.nil? || avdmanager_path == "avdmanager") && android_home
54
+ # First search for cmdline-tools dir
55
+ cmdline_tools_path = File.join(android_home, "cmdline-tools")
56
+
57
+ # Find the first available 'bin' folder within cmdline-tools
58
+ available_path = Dir.glob(File.join(cmdline_tools_path, "*", "bin")).first
59
+ raise "No valid bin path found in #{cmdline_tools_path}" unless available_path
60
+
61
+ avdmanager_path = File.join(available_path, "avdmanager")
62
+ end
63
+
64
+ self.avdmanager_path = Helper.get_executable_path(File.expand_path(avdmanager_path))
65
+ end
66
+
67
+ def trigger(command: nil)
68
+ raise "avdmanager_path is not set" unless avdmanager_path
69
+
70
+ # Build and execute the command
71
+ command = [avdmanager_path.shellescape, command].compact.join(" ").strip
72
+ Action.sh(command)
73
+ end
74
+
75
+ # Create a new AVD
76
+ def create_avd(name:, package:, device: "pixel_7_pro")
77
+ raise "AVD name is required" if name.nil? || name.empty?
78
+ raise "System image package is required" if package.nil? || package.empty?
79
+
80
+ command = [
81
+ "create avd",
82
+ "-n #{name.shellescape}",
83
+ "-f",
84
+ "-k \"#{package}\"",
85
+ "-d #{device.shellescape}"
86
+ ].join(" ")
87
+
88
+ trigger(command: command)
89
+ end
90
+ end
91
+
92
+ class EmulatorHelper
93
+ attr_accessor :emulator_path
94
+
95
+ def initialize(emulator_path: nil)
96
+ android_home = ENV.fetch('ANDROID_HOME', nil) || ENV.fetch('ANDROID_SDK_ROOT', nil)
97
+ if (emulator_path.nil? || emulator_path == "avdmanager") && android_home
98
+ emulator_path = File.join(android_home, "emulator", "emulator")
99
+ end
100
+
101
+ self.emulator_path = Helper.get_executable_path(File.expand_path(emulator_path))
102
+ end
103
+
104
+ def trigger(command: nil)
105
+ raise "emulator_path is not set" unless emulator_path
106
+
107
+ # Build and execute the command
108
+ command = [emulator_path.shellescape, command].compact.join(" ").strip
109
+ Action.sh(command)
110
+ end
111
+
112
+ # Start an emulator instance
113
+ def start_emulator(name:, port:)
114
+ raise "Emulator name is required" if name.nil? || name.empty?
115
+ raise "Port is required" if port.nil? || port.to_s.empty?
116
+
117
+ command = [
118
+ "-avd #{name.shellescape}",
119
+ "-port #{port.shellescape}",
120
+ "-wipe-data",
121
+ "-no-boot-anim",
122
+ "-no-snapshot",
123
+ "-no-audio",
124
+ "> /dev/null 2>&1 &"
125
+ ].join(" ")
126
+
127
+ UI.message("Starting emulator #{name} on port #{port}...")
128
+ trigger(command: command)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module MaestroOrchestration
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require 'fastlane/plugin/maestro_orchestration/version'
2
+
3
+ module Fastlane
4
+ module MaestroOrchestration
5
+ # Return all .rb files inside the "actions" and "helper" directory
6
+ def self.all_classes
7
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
8
+ end
9
+ end
10
+ end
11
+
12
+ # By default we want to import all available actions and helpers
13
+ # A plugin can contain any number of actions and plugins
14
+ Fastlane::MaestroOrchestration.all_classes.each do |current|
15
+ require current
16
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-maestro_orchestration
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nemanja Risteski
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fastlane-plugin-android_emulator
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.2.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '1.2'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.2.1
33
+ description:
34
+ email: nemanja.risteski@sourcetoad.com
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - LICENSE
40
+ - README.md
41
+ - lib/fastlane/plugin/maestro_orchestration.rb
42
+ - lib/fastlane/plugin/maestro_orchestration/actions/maestro_orchestration_android_action.rb
43
+ - lib/fastlane/plugin/maestro_orchestration/actions/maestro_orchestration_api_request_action.rb
44
+ - lib/fastlane/plugin/maestro_orchestration/actions/maestro_orchestration_ios_action.rb
45
+ - lib/fastlane/plugin/maestro_orchestration/actions/maestro_orchestration_s3_upload_action.rb
46
+ - lib/fastlane/plugin/maestro_orchestration/helper/maestro_orchestration_helper.rb
47
+ - lib/fastlane/plugin/maestro_orchestration/version.rb
48
+ homepage: https://github.com/sourcetoad/fastlane-plugin-maestro_orchestration
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ rubygems_mfa_required: 'true'
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.6'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.1.6
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Plugin for maestro testing framework.
72
+ test_files: []