├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── fastlane-plugin-firebase_test_lab.gemspec ├── lib └── fastlane │ └── plugin │ ├── firebase_test_lab.rb │ └── firebase_test_lab │ ├── actions │ └── firebase_test_lab_ios_xctest.rb │ ├── helper │ ├── credential.rb │ ├── error_helper.rb │ ├── ftl_message.rb │ ├── ftl_service.rb │ ├── ios_validator.rb │ └── storage.rb │ ├── module.rb │ └── options.rb └── testlab.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | Gemfile.lock 3 | 4 | ## Documentation cache and generated files: 5 | /.yardoc/ 6 | /_yardoc/ 7 | /doc/ 8 | /rdoc/ 9 | 10 | .idea 11 | *.iml 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format d 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/PercentLiteralDelimiters: 2 | Enabled: false 3 | 4 | Style/NumericPredicate: 5 | Enabled: false 6 | 7 | # this would cause errors with long lanes 8 | Metrics/BlockLength: 9 | Enabled: false 10 | 11 | # Catch all errors 12 | Lint/RescueWithoutErrorClass: 13 | Enabled: false 14 | 15 | Metrics/AbcSize: 16 | Enabled: false 17 | 18 | Metrics/MethodLength: 19 | Enabled: false 20 | 21 | Metrics/CyclomaticComplexity: 22 | Enabled: false 23 | 24 | # Better too much 'return' than one missing 25 | Style/RedundantReturn: 26 | Enabled: false 27 | 28 | # Having if in the same line might not always be good 29 | Style/IfUnlessModifier: 30 | Enabled: false 31 | 32 | # Configuration parameters: CountComments. 33 | Metrics/ClassLength: 34 | Max: 320 35 | 36 | 37 | # Configuration parameters: AllowURI, URISchemes. 38 | Metrics/LineLength: 39 | Max: 370 40 | 41 | # Configuration parameters: CountKeywordArgs. 42 | Metrics/ParameterLists: 43 | Max: 17 44 | 45 | Metrics/PerceivedComplexity: 46 | Max: 18 47 | 48 | # Sometimes it's easier to read without guards 49 | Style/GuardClause: 50 | Enabled: false 51 | 52 | # We allow both " and ' 53 | Style/StringLiterals: 54 | Enabled: false 55 | 56 | # e.g. 57 | # def self.is_supported?(platform) 58 | # we may never use `platform` 59 | Lint/UnusedMethodArgument: 60 | Enabled: false 61 | 62 | # This would reject is_ in front of methods 63 | # We use `is_supported?` everywhere already 64 | Style/PredicateName: 65 | Enabled: false 66 | 67 | AllCops: 68 | TargetRubyVersion: 2.0 69 | Include: 70 | - '*/lib/assets/*Template' 71 | - '*/lib/assets/*TemplateAndroid' 72 | Exclude: 73 | - '**/lib/assets/custom_action_template.rb' 74 | - './vendor/**/*' 75 | - '**/lib/assets/DefaultFastfileTemplate' 76 | - '**/spec/fixtures/broken_files/broken_file.rb' 77 | 78 | # They have not to be snake_case 79 | Style/FileName: 80 | Exclude: 81 | - '**/Dangerfile' 82 | - '**/Brewfile' 83 | - '**/Gemfile' 84 | - '**/Podfile' 85 | - '**/Rakefile' 86 | - '**/Fastfile' 87 | - '**/Deliverfile' 88 | - '**/Snapfile' 89 | - '**/*.gemspec' 90 | 91 | # We're not there yet 92 | Style/Documentation: 93 | Enabled: false 94 | 95 | # Added after upgrade to 0.38.0 96 | Style/MutableConstant: 97 | Enabled: false 98 | 99 | # ( ) for method calls 100 | Style/MethodCallWithArgsParentheses: 101 | Enabled: true 102 | IgnoredMethods: 103 | - 'require' 104 | - 'require_relative' 105 | - 'fastlane_require' 106 | - 'gem' 107 | - 'program' 108 | - 'command' 109 | - 'raise' 110 | - 'attr_accessor' 111 | - 'attr_reader' 112 | - 'desc' 113 | - 'lane' 114 | - 'private_lane' 115 | - 'platform' 116 | # rspec tests code below 117 | - 'to' 118 | - 'describe' 119 | - 'it' 120 | - 'be' 121 | - 'context' 122 | - 'before' 123 | - 'after' 124 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source('https://rubygems.org') 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present the fastlane authors 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. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # `Firebase Test Lab` plugin for _fastlane_ 6 | 7 | This project is a [fastlane](https://fastlane.tools) plugin. You can add it to your _fastlane_ project by running 8 | 9 | ```bash 10 | fastlane add_plugin firebase_test_lab 11 | ``` 12 | 13 | --- 14 | 15 | [![fastlane Firebase Test Lab plugin](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-firebase_test_lab) 16 | 17 | ## About Firebase Test Lab plugin 18 | 19 | [Firebase Test Lab](https://firebase.google.com/docs/test-lab/) let you easily test your iOS app (Android support forthcoming) on a variety of real or virtual devices and configurations. This plugin allows you to submit your app to Firebase Test Lab by adding an action into your `Fastfile`. 20 | 21 | ## Getting started 22 | 23 | ### If you are not current user of Firebase 24 | 25 | You need to set up Firebase first. These only needs to be done once for an organization. 26 | 27 | - If you have not used Google Cloud before, you need to [create a new Google Cloud project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#Creating%20a%20Project) first. 28 | - Go to the [Firebase Console](https://console.firebase.google.com/). Add Firebase into your Google Cloud project by clicking on "Add project" and then choose your just-created project. 29 | 30 | ### Configure Google credentials through service accounts 31 | 32 | To authenticate, Google Cloud credentials will need to be set for any machine where _fastlane_ and this plugin runs on. 33 | 34 | If you are running this plugin on Google Cloud [Compute Engine](https://cloud.google.com/compute), [Kubernetes Engine](https://cloud.google.com/kubernetes-engine) or [App Engine flexible environment](https://cloud.google.com/appengine/docs/flexible/), a default service account is automatically provisioned. You will not need to create a service account. See [this](https://cloud.google.com/compute/docs/access/service-accounts#compute_engine_default_service_account) for more details. 35 | 36 | In all other cases, you need to configure the service account manually. You can follow [this guide](https://cloud.google.com/docs/authentication/getting-started) on how to create a new service account and create a key for it. You will need to set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable pointing to the service account key file according to the document. 37 | 38 | No matter if you are a using an automatically provisioned service account or a manually created one, the service account must be configured to have the project editor role. 39 | 40 | ### Enable relevant Google APIs 41 | 42 | - You need to enable the following APIs on your [Google Cloud API library](https://console.cloud.google.com/apis/library) (see [this](https://support.google.com/cloud/answer/6158841) for instructions how): 43 | 1. Cloud Testing API 44 | 2. Cloud Tool Results API 45 | 46 | ### Find the devices you want to test on 47 | 48 | If you have [gcloud tool](https://cloud.google.com/sdk/gcloud/), you can run 49 | 50 | ```no-highlight 51 | gcloud beta firebase test ios models list 52 | ``` 53 | 54 | to get a list of supported devices and their identifiers. 55 | 56 | Alternatively all available devices can also be seen [here](https://firebase.google.com/docs/test-lab/ios/available-testing-devices). 57 | 58 | ## Actions 59 | 60 | ### `firebase_test_lab_ios_xctest` 61 | 62 | Submit your iOS app to Firebase Test Lab and run XCTest. Refer to [this document](https://firebase.google.com/docs/test-lab/ios/command-line) for more details about Firebase Test Lab specific arguments. 63 | 64 | ```ruby 65 | scan( 66 | scheme: 'YourApp', # XCTest scheme 67 | clean: true, # Recommended: This would ensure the build would not include unnecessary files 68 | skip_detect_devices: true, # Required 69 | build_for_testing: true, # Required 70 | sdk: 'iphoneos', # Required 71 | should_zip_build_products: true # Must be true to set the correct format for Firebase Test Lab 72 | ) 73 | firebase_test_lab_ios_xctest( 74 | gcp_project: 'your-google-project', # Your Google Cloud project name 75 | devices: [ # Device(s) to run tests on 76 | { 77 | ios_model_id: 'iphonex', # Device model ID, see gcloud command above 78 | ios_version_id: '11.2', # iOS version ID, see gcloud command above 79 | locale: 'en_US', # Optional: default to en_US if not set 80 | orientation: 'portrait' # Optional: default to portrait if not set 81 | } 82 | ] 83 | ) 84 | ``` 85 | 86 | **Available parameters:** 87 | 88 | - `app_path`: You may provide a different path in the local filesystem (e.g: `/path/to/app-bundle.zip`) or on Google Cloud Storage (`gs://your-bucket/path/to/app-bundle.zip`) that points to an app bundle as specified [here](https://firebase.google.com/docs/test-lab/ios/command-line#build_xctests_for_your_app). If a Google Cloud Storage path is used, the service account must have read access to such file. 89 | - `gcp_project`: The Google Cloud project name for Firebase Test Lab to run on. 90 | - `gcp_requests_timeout`: The timeout, in seconds, to use for all requests to the Google Cloud platform (e.g. uploading your app bundle ZIP). If this parameter is omitted, Google Cloud SDK's default requests timeout value will be used. If you are finding that your ZIP uploads are timing out due to the ZIP file being large and not completing within the set timeout time, increase this value. 91 | - `gcp_additional_client_info`: Additional information to include in the client information of your test job. For example, if you'd like to include metadata to use in a Google cloud function in response to your test job's outcome. See https://firebase.google.com/docs/reference/functions/functions.testLab.ClientInfo for more information. 92 | - `oauth_key_file_path`: The path to the Google Cloud service account key. If not set, the Google application default credential will be used. 93 | - `devices`: An array of devices for your app to be tested on. Each device is represented as a ruby hash, with `ios_model_id`, `ios_version_id`, `locale` and `orientation` properties, the first two of which are required. If not set, it will default to iPhone X on iOS 11.2. This array cannot be empty. 94 | - `async`: If set to true, the action will not wait for the test results but exit immediately. 95 | - `timeout_sec`: After how long will the test be abandoned by Firebase Test Lab. Duration should be given in seconds. 96 | - `result_storage`: Designate which location on Google Cloud Storage to store the test results. This should be a directory (e.g: `gs://your-bucket/tests/`) 97 | 98 | ## Issues and Feedback 99 | 100 | If you have any other issues and feedback about this plugin, we appreciate if you could submit an issue to this repository. 101 | 102 | You can also join the Firebase slack channel [here](https://firebase.community/). 103 | 104 | ## Troubleshooting 105 | 106 | For some more detailed help with plugins problems, check out the [Plugins Troubleshooting](https://github.com/fastlane/fastlane/blob/master/fastlane/docs/PluginsTroubleshooting.md) doc in the main _fastlane_ repo. 107 | 108 | ## Using _fastlane_ Plugins 109 | 110 | For more information about how the _fastlane_ plugin system works, check out the [Plugins documentation](https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Plugins.md) in the main _fastlane_ repo. 111 | 112 | ## About _fastlane_ 113 | 114 | _fastlane_ automates building, testing, and releasing your app for beta and app store distributions. To learn more about _fastlane_, check out [fastlane.tools](https://fastlane.tools). 115 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /fastlane-plugin-firebase_test_lab.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'fastlane/plugin/firebase_test_lab/module' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'fastlane-plugin-firebase_test_lab' 8 | spec.version = Fastlane::FirebaseTestLab::VERSION 9 | spec.author = 'Shihua Zheng' 10 | spec.email = 'shihuaz@google.com' 11 | 12 | spec.summary = 'Test your app with Firebase Test Lab with ease using fastlane' 13 | spec.homepage = "https://github.com/fastlane/fastlane-plugin-firebase_test_lab" 14 | spec.license = "MIT" 15 | 16 | spec.files = Dir["lib/**/*"] + %w(README.md LICENSE) 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_dependency('faraday') 21 | spec.add_dependency('googleauth') 22 | spec.add_dependency('rubyzip', '>= 1.0.0') 23 | spec.add_dependency('plist', '>= 3.0.0') 24 | spec.add_dependency('google-cloud-storage', '>= 1.25.1', '<2.0.0') 25 | spec.add_dependency('tty-spinner', '>= 0.8.0', '< 1.0.0') 26 | 27 | spec.add_development_dependency('bundler') 28 | spec.add_development_dependency('fastlane', '>= 2.102.0') 29 | spec.add_development_dependency('rubocop', '<= 0.50.0') 30 | end 31 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab.rb: -------------------------------------------------------------------------------- 1 | require 'fastlane/plugin/firebase_test_lab/module' 2 | 3 | module Fastlane 4 | module FirebaseTestLab 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::FirebaseTestLab.all_classes.each do |current| 15 | require current 16 | end 17 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/actions/firebase_test_lab_ios_xctest.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper/ftl_service' 2 | require_relative '../helper/ftl_message' 3 | require_relative '../helper/storage' 4 | require_relative '../helper/credential' 5 | require_relative '../helper/ios_validator' 6 | require_relative '../options' 7 | 8 | require 'json' 9 | require 'securerandom' 10 | require 'tty-spinner' 11 | 12 | module Fastlane 13 | module Actions 14 | class FirebaseTestLabIosXctestAction < Action 15 | DEFAULT_APP_BUNDLE_NAME = "bundle" 16 | PULL_RESULT_INTERVAL = 5 17 | 18 | RUNNING_STATES = %w(VALIDATING PENDING RUNNING) 19 | 20 | private_constant :DEFAULT_APP_BUNDLE_NAME 21 | private_constant :PULL_RESULT_INTERVAL 22 | private_constant :RUNNING_STATES 23 | 24 | def self.run(params) 25 | gcp_project = params[:gcp_project] 26 | gcp_requests_timeout = params[:gcp_requests_timeout] 27 | oauth_key_file_path = params[:oauth_key_file_path] 28 | gcp_credential = Fastlane::FirebaseTestLab::Credential.new(key_file_path: oauth_key_file_path) 29 | 30 | ftl_service = Fastlane::FirebaseTestLab::FirebaseTestLabService.new(gcp_credential) 31 | 32 | # The default Google Cloud Storage path we store app bundle and test results 33 | gcs_workfolder = generate_directory_name 34 | 35 | # Firebase Test Lab requires an app bundle be already on Google Cloud Storage before starting the job 36 | if params[:app_path].to_s.start_with?("gs://") 37 | # gs:// is a path on Google Cloud Storage, we do not need to re-upload the app to a different bucket 38 | app_gcs_link = params[:app_path] 39 | else 40 | 41 | if params[:skip_validation] 42 | UI.message("Skipping validation of app.") 43 | else 44 | FirebaseTestLab::IosValidator.validate_ios_app(params[:app_path]) 45 | end 46 | 47 | # When given a local path, we upload the app bundle to Google Cloud Storage 48 | upload_spinner = TTY::Spinner.new("[:spinner] Uploading the app to GCS...", format: :dots) 49 | upload_spinner.auto_spin 50 | upload_bucket_name = ftl_service.get_default_bucket(gcp_project) 51 | timeout = gcp_requests_timeout ? gcp_requests_timeout.to_i : nil 52 | app_gcs_link = upload_file(params[:app_path], 53 | upload_bucket_name, 54 | "#{gcs_workfolder}/#{DEFAULT_APP_BUNDLE_NAME}", 55 | gcp_project, 56 | gcp_credential, 57 | timeout) 58 | upload_spinner.success("Done") 59 | end 60 | 61 | UI.message("Submitting job(s) to Firebase Test Lab") 62 | 63 | result_storage = (params[:result_storage] || 64 | "gs://#{ftl_service.get_default_bucket(gcp_project)}/#{gcs_workfolder}") 65 | UI.message("Test Results bucket: #{result_storage}") 66 | 67 | # We have gathered all the information. Call Firebase Test Lab to start the job now 68 | matrix_id = ftl_service.start_job(gcp_project, 69 | app_gcs_link, 70 | result_storage, 71 | params[:devices], 72 | params[:timeout_sec], 73 | params[:gcp_additional_client_info]) 74 | 75 | # In theory, matrix_id should be available. Keep it to catch unexpected Firebase Test Lab API response 76 | if matrix_id.nil? 77 | UI.abort_with_message!("No matrix ID received.") 78 | end 79 | UI.message("Matrix ID for this submission: #{matrix_id}") 80 | wait_for_test_results(ftl_service, gcp_project, matrix_id, params[:async]) 81 | end 82 | 83 | def self.upload_file(app_path, bucket_name, gcs_path, gcp_project, gcp_credential, gcp_requests_timeout) 84 | file_name = "gs://#{bucket_name}/#{gcs_path}" 85 | storage = Fastlane::FirebaseTestLab::Storage.new(gcp_project, gcp_credential, gcp_requests_timeout) 86 | storage.upload_file(File.expand_path(app_path), bucket_name, gcs_path) 87 | return file_name 88 | end 89 | 90 | def self.wait_for_test_results(ftl_service, gcp_project, matrix_id, async) 91 | firebase_console_link = nil 92 | 93 | spinner = TTY::Spinner.new("[:spinner] Starting tests...", format: :dots) 94 | spinner.auto_spin 95 | 96 | # Keep pulling test results until they are ready 97 | loop do 98 | results = ftl_service.get_matrix_results(gcp_project, matrix_id) 99 | 100 | if firebase_console_link.nil? 101 | history_id, execution_id = try_get_history_id_and_execution_id(results) 102 | # Once we get the Firebase console link, we display that exactly once 103 | unless history_id.nil? || execution_id.nil? 104 | firebase_console_link = "https://console.firebase.google.com" \ 105 | "/project/#{gcp_project}/testlab/histories/#{history_id}/matrices/#{execution_id}" 106 | 107 | spinner.success("Done") 108 | UI.message("Go to #{firebase_console_link} for more information about this run") 109 | 110 | if async 111 | UI.success("Job(s) have been submitted to Firebase Test Lab") 112 | return 113 | end 114 | 115 | spinner = TTY::Spinner.new("[:spinner] Waiting for results...", format: :dots) 116 | spinner.auto_spin 117 | end 118 | end 119 | 120 | state = results["state"] 121 | # Handle all known error statuses 122 | if FirebaseTestLab::ERROR_STATE_TO_MESSAGE.key?(state.to_sym) 123 | spinner.error("Failed") 124 | invalid_matrix_details = results["invalidMatrixDetails"] 125 | if invalid_matrix_details && 126 | FirebaseTestLab::INVALID_MATRIX_DETAIL_TO_MESSAGE.key?(invalid_matrix_details.to_sym) 127 | UI.error(FirebaseTestLab::INVALID_MATRIX_DETAIL_TO_MESSAGE[invalid_matrix_details.to_sym]) 128 | end 129 | UI.user_error!(FirebaseTestLab::ERROR_STATE_TO_MESSAGE[state.to_sym]) 130 | end 131 | 132 | if state == "FINISHED" 133 | spinner.success("Done") 134 | # Inspect the execution results: only contain info on whether each job finishes. 135 | # Do not include whether tests fail 136 | executions_completed = extract_execution_results(results) 137 | 138 | if results["resultStorage"].nil? || results["resultStorage"]["toolResultsExecution"].nil? 139 | UI.abort_with_message!("Unexpected response from Firebase test lab: Cannot retrieve result info") 140 | end 141 | 142 | # Now, look at the actual test result and see if they succeed 143 | history_id, execution_id = try_get_history_id_and_execution_id(results) 144 | if history_id.nil? || execution_id.nil? 145 | UI.abort_with_message!("Unexpected response from Firebase test lab: No history or execution ID") 146 | end 147 | test_results = ftl_service.get_execution_steps(gcp_project, history_id, execution_id) 148 | tests_successful = extract_test_results(test_results, gcp_project, history_id, execution_id) 149 | unless executions_completed && tests_successful 150 | UI.test_failure!("Tests failed. " \ 151 | "Go to #{firebase_console_link} for more information about this run") 152 | end 153 | return 154 | end 155 | 156 | # We should have caught all known states here. If the state is not one of them, this 157 | # plugin should be modified to handle that 158 | unless RUNNING_STATES.include?(state) 159 | spinner.error("Failed") 160 | UI.abort_with_message!("The test execution is in an unknown state: #{state}. " \ 161 | "We appreciate if you could notify us at " \ 162 | "https://github.com/fastlane/fastlane-plugin-firebase_test_lab/issues") 163 | end 164 | sleep(PULL_RESULT_INTERVAL) 165 | end 166 | end 167 | 168 | def self.generate_directory_name 169 | timestamp = Time.now.getutc.strftime("%Y%m%d-%H%M%SZ") 170 | return "fastlane-#{timestamp}-#{SecureRandom.hex[0..5]}" 171 | end 172 | 173 | def self.try_get_history_id_and_execution_id(matrix_results) 174 | if matrix_results["resultStorage"].nil? || matrix_results["resultStorage"]["toolResultsExecution"].nil? 175 | return nil, nil 176 | end 177 | 178 | tool_results_execution = matrix_results["resultStorage"]["toolResultsExecution"] 179 | history_id = tool_results_execution["historyId"] 180 | execution_id = tool_results_execution["executionId"] 181 | return history_id, execution_id 182 | end 183 | 184 | def self.extract_execution_results(execution_results) 185 | UI.message("Test job(s) are finalized") 186 | UI.message("-------------------------") 187 | UI.message("| EXECUTION RESULTS |") 188 | failures = 0 189 | execution_results["testExecutions"].each do |execution| 190 | UI.message("-------------------------") 191 | execution_info = "#{execution['id']}: #{execution['state']}" 192 | if execution["state"] != "FINISHED" 193 | failures += 1 194 | UI.error(execution_info) 195 | else 196 | UI.success(execution_info) 197 | end 198 | 199 | # Display build logs 200 | if !execution["testDetails"].nil? && !execution["testDetails"]["progressMessages"].nil? 201 | execution["testDetails"]["progressMessages"].each { |msg| UI.message(msg) } 202 | end 203 | end 204 | 205 | UI.message("-------------------------") 206 | if failures > 0 207 | UI.error("😞 #{failures} execution(s) have failed to complete.") 208 | else 209 | UI.success("🎉 All jobs have ran and completed.") 210 | end 211 | return failures == 0 212 | end 213 | 214 | def self.extract_test_results(test_results, gcp_project, history_id, execution_id) 215 | steps = test_results["steps"] 216 | failures = 0 217 | inconclusive_runs = 0 218 | 219 | UI.message("-------------------------") 220 | UI.message("| TEST OUTCOME |") 221 | steps.each do |step| 222 | UI.message("-------------------------") 223 | step_id = step["stepId"] 224 | UI.message("Test step: #{step_id}") 225 | 226 | run_duration_sec = step["runDuration"]["seconds"] || 0 227 | UI.message("Execution time: #{run_duration_sec} seconds") 228 | 229 | outcome = step["outcome"]["summary"] 230 | case outcome 231 | when "success" 232 | UI.success("Result: #{outcome}") 233 | when "skipped" 234 | UI.message("Result: #{outcome}") 235 | when "inconclusive" 236 | inconclusive_runs += 1 237 | UI.error("Result: #{outcome}") 238 | when "failure" 239 | failures += 1 240 | UI.error("Result: #{outcome}") 241 | end 242 | UI.message("For details, go to https://console.firebase.google.com/project/#{gcp_project}/testlab/" \ 243 | "histories/#{history_id}/matrices/#{execution_id}/executions/#{step_id}") 244 | end 245 | 246 | UI.message("-------------------------") 247 | if failures == 0 && inconclusive_runs == 0 248 | UI.success("🎉 Yay! All executions are completed successfully!") 249 | end 250 | if failures > 0 251 | UI.error("😞 #{failures} step(s) have failed.") 252 | end 253 | if inconclusive_runs > 0 254 | UI.error("😞 #{inconclusive_runs} step(s) yielded inconclusive outcomes.") 255 | end 256 | return failures == 0 && inconclusive_runs == 0 257 | end 258 | 259 | ##################################################### 260 | # @!group Documentation 261 | ##################################################### 262 | 263 | def self.description 264 | "Submit an iOS XCTest job to Firebase Test Lab" 265 | end 266 | 267 | def self.available_options 268 | Fastlane::FirebaseTestLab::Options.available_options 269 | end 270 | 271 | def self.authors 272 | ["powerivq"] 273 | end 274 | 275 | def self.is_supported?(platform) 276 | return platform == :ios 277 | end 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/credential.rb: -------------------------------------------------------------------------------- 1 | require 'googleauth' 2 | 3 | module Fastlane 4 | module FirebaseTestLab 5 | class Credential 6 | def initialize(key_file_path: nil) 7 | @key_file_path = key_file_path 8 | end 9 | 10 | def get_google_credential(scopes) 11 | unless @key_file_path 12 | begin 13 | return Google::Auth.get_application_default(scopes) 14 | rescue => ex 15 | UI.abort_with_message!("Failed reading application default credential. Either the Oauth credential should be provided or Google Application Default Credential should be configured: #{ex.message}") 16 | end 17 | end 18 | 19 | File.open(File.expand_path(@key_file_path), "r") do |file| 20 | options = { 21 | json_key_io: file, 22 | scope: scopes 23 | } 24 | begin 25 | return Google::Auth::ServiceAccountCredentials.make_creds(options) 26 | rescue => ex 27 | UI.abort_with_message!("Failed reading OAuth credential: #{ex.message}") 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/error_helper.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Fastlane 4 | module FirebaseTestLab 5 | class ErrorHelper 6 | def self.summarize_google_error(payload) 7 | begin 8 | response = JSON.parse(payload) 9 | rescue JSON::ParserError => ex 10 | FastlaneCore::UI.error("Unable to parse error message: #{ex.class}, message: #{ex.message}") 11 | return payload 12 | end 13 | 14 | if response["error"] 15 | return "#{response['error']['message']}\n#{payload}" 16 | end 17 | return payload 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/ftl_message.rb: -------------------------------------------------------------------------------- 1 | module Fastlane 2 | module FirebaseTestLab 3 | ERROR_STATE_TO_MESSAGE = { 4 | ERROR: "The execution or matrix has stopped because it encountered an infrastructure failure.", 5 | UNSUPPORTED_ENVIRONMENT: "The execution was not run because it corresponds to a unsupported environment.", 6 | INCOMPATIBLE_ENVIRONMENT: "The execution was not run because the provided inputs are incompatible with the " \ 7 | "requested environment", 8 | INCOMPATIBLE_ARCHITECTURE: "The execution was not run because the provided inputs are incompatible with the " \ 9 | "requested architecture.", 10 | CANCELLED: "The user cancelled the execution.", 11 | INVALID: "The execution or matrix was not run because the provided inputs are not valid." 12 | } 13 | 14 | INVALID_MATRIX_DETAIL_TO_MESSAGE = { 15 | MALFORMED_APK: "The app APK is not a valid Android application", 16 | MALFORMED_TEST_APK: "The test APK is not a valid Android instrumentation test", 17 | NO_MANIFEST: "The app APK is missing the manifest file", 18 | NO_PACKAGE_NAME: "The APK manifest file is missing the package name", 19 | TEST_SAME_AS_APP: "The test APK is the same as the app APK", 20 | NO_INSTRUMENTATION: "The test APK declares no instrumentation tags in the manifest", 21 | NO_SIGNATURE: "At least one supplied APK file has a missing or invalid signature", 22 | INSTRUMENTATION_ORCHESTRATOR_INCOMPATIBLE: "The test runner class specified by the user or the test APK\"s " \ 23 | "manifest file is not compatible with Android Test Orchestrator. " \ 24 | "Please use AndroidJUnitRunner version 1.0 or higher", 25 | NO_TEST_RUNNER_CLASS: "The test APK does not contain the test runner class specified by " \ 26 | "the user or the manifest file. The test runner class name may be " \ 27 | "incorrect, or the class may be mislocated in the app APK.", 28 | NO_LAUNCHER_ACTIVITY: "The app APK does not specify a main launcher activity", 29 | FORBIDDEN_PERMISSIONS: "The app declares one or more permissions that are not allowed", 30 | INVALID_ROBO_DIRECTIVES: "Cannot have multiple robo-directives with the same resource name", 31 | TEST_LOOP_INTENT_FILTER_NOT_FOUND: "The app does not have a correctly formatted game-loop intent filter", 32 | SCENARIO_LABEL_NOT_DECLARED: "A scenario-label was not declared in the manifest file", 33 | SCENARIO_LABEL_MALFORMED: "A scenario-label in the manifest includes invalid numbers or ranges", 34 | SCENARIO_NOT_DECLARED: "A scenario-number was not declared in the manifest file", 35 | DEVICE_ADMIN_RECEIVER: "Device administrator applications are not allowed", 36 | MALFORMED_XC_TEST_ZIP: "The XCTest zip file was malformed. The zip did not contain a single " \ 37 | ".xctestrun file and the contents of the DerivedData/Build/Products directory.", 38 | BUILT_FOR_IOS_SIMULATOR: "The provided XCTest was built for the iOS simulator rather than for " \ 39 | "a physical device", 40 | NO_TESTS_IN_XC_TEST_ZIP: "The .xctestrun file did not specify any test targets to run", 41 | USE_DESTINATION_ARTIFACTS: "One or more of the test targets defined in the .xctestrun file " \ 42 | "specifies \"UseDestinationArtifacts\", which is not allowed", 43 | TEST_NOT_APP_HOSTED: "One or more of the test targets defined in the .xctestrun file " \ 44 | "does not have a host binary to run on the physical iOS device, " \ 45 | "which may cause errors when running xcodebuild", 46 | NO_CODE_APK: "\"hasCode\" is false in the Manifest. Tested APKs must contain code", 47 | INVALID_INPUT_APK: "Either the provided input APK path was malformed, the APK file does " \ 48 | "not exist, or the user does not have permission to access the file" 49 | } 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/ftl_service.rb: -------------------------------------------------------------------------------- 1 | require 'googleauth' 2 | require 'json' 3 | 4 | require_relative './error_helper' 5 | require_relative '../module' 6 | 7 | module Fastlane 8 | module FirebaseTestLab 9 | class FirebaseTestLabService 10 | APIARY_ENDPOINT = "https://www.googleapis.com" 11 | TOOLRESULTS_GET_SETTINGS_API_V3 = "/toolresults/v1beta3/projects/{project}/settings" 12 | TOOLRESULTS_INITIALIZE_SETTINGS_API_V3 = "/toolresults/v1beta3/projects/{project}:initializeSettings" 13 | TOOLRESULTS_LIST_EXECUTION_STEP_API_V3 = 14 | "/toolresults/v1beta3/projects/{project}/histories/{history_id}/executions/{execution_id}/steps" 15 | 16 | FIREBASE_TEST_LAB_ENDPOINT = "https://testing.googleapis.com" 17 | FTL_CREATE_API = "/v1/projects/{project}/testMatrices" 18 | FTL_RESULTS_API = "/v1/projects/{project}/testMatrices/{matrix}" 19 | 20 | TESTLAB_OAUTH_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] 21 | 22 | private_constant :APIARY_ENDPOINT 23 | private_constant :TOOLRESULTS_GET_SETTINGS_API_V3 24 | private_constant :TOOLRESULTS_INITIALIZE_SETTINGS_API_V3 25 | private_constant :TOOLRESULTS_LIST_EXECUTION_STEP_API_V3 26 | private_constant :FIREBASE_TEST_LAB_ENDPOINT 27 | private_constant :FTL_CREATE_API 28 | private_constant :FTL_RESULTS_API 29 | private_constant :TESTLAB_OAUTH_SCOPES 30 | 31 | def initialize(credential) 32 | @auth = credential.get_google_credential(TESTLAB_OAUTH_SCOPES) 33 | @default_bucket = nil 34 | end 35 | 36 | def init_default_bucket(gcp_project) 37 | conn = Faraday.new(APIARY_ENDPOINT) 38 | begin 39 | conn.post(TOOLRESULTS_INITIALIZE_SETTINGS_API_V3.gsub("{project}", gcp_project)) do |req| 40 | req.headers = @auth.apply(req.headers) 41 | req.options.timeout = 15 42 | req.options.open_timeout = 5 43 | end 44 | rescue Faraday::Error => ex 45 | UI.abort_with_message!("Network error when initializing Firebase Test Lab, " \ 46 | "type: #{ex.class}, message: #{ex.message}") 47 | end 48 | end 49 | 50 | def get_default_bucket(gcp_project) 51 | return @default_bucket unless @default_bucket.nil? 52 | 53 | init_default_bucket(gcp_project) 54 | conn = Faraday.new(APIARY_ENDPOINT) 55 | begin 56 | resp = conn.get(TOOLRESULTS_GET_SETTINGS_API_V3.gsub("{project}", gcp_project)) do |req| 57 | req.headers = @auth.apply(req.headers) 58 | req.options.timeout = 15 59 | req.options.open_timeout = 5 60 | end 61 | rescue Faraday::Error => ex 62 | UI.abort_with_message!("Network error when obtaining Firebase Test Lab default GCS bucket, " \ 63 | "type: #{ex.class}, message: #{ex.message}") 64 | end 65 | 66 | if resp.status != 200 67 | FastlaneCore::UI.error("Failed to obtain default bucket for Firebase Test Lab.") 68 | summarized_error = ErrorHelper.summarize_google_error(resp.body) 69 | if summarized_error.include?("Not Authorized for project") 70 | FastlaneCore::UI.error("Please make sure that the account associated with your Google credential is the " \ 71 | "project editor or owner. You can do this at the Google Developer Console " \ 72 | "https://console.cloud.google.com/iam-admin/iam?project=#{gcp_project}") 73 | end 74 | FastlaneCore::UI.abort_with_message!(summarized_error) 75 | return nil 76 | else 77 | response_json = JSON.parse(resp.body) 78 | @default_bucket = response_json["defaultBucket"] 79 | return @default_bucket 80 | end 81 | end 82 | 83 | def start_job(gcp_project, app_path, result_path, devices, timeout_sec, additional_client_info) 84 | if additional_client_info.nil? 85 | additional_client_info = { version: VERSION } 86 | else 87 | additional_client_info["version"] = VERSION 88 | end 89 | additional_client_info = additional_client_info.map { |k,v| { key: k, value: v } } 90 | 91 | body = { 92 | projectId: gcp_project, 93 | testSpecification: { 94 | testTimeout: { 95 | seconds: timeout_sec 96 | }, 97 | iosTestSetup: {}, 98 | iosXcTest: { 99 | testsZip: { 100 | gcsPath: app_path 101 | } 102 | } 103 | }, 104 | environmentMatrix: { 105 | iosDeviceList: { 106 | iosDevices: devices.map(&FirebaseTestLabService.method(:map_device_to_proto)) 107 | } 108 | }, 109 | resultStorage: { 110 | googleCloudStorage: { 111 | gcsPath: result_path 112 | } 113 | }, 114 | clientInfo: { 115 | name: PLUGIN_NAME, 116 | clientInfoDetails: [ 117 | additional_client_info 118 | ] 119 | } 120 | } 121 | 122 | conn = Faraday.new(FIREBASE_TEST_LAB_ENDPOINT) 123 | begin 124 | resp = conn.post(FTL_CREATE_API.gsub("{project}", gcp_project)) do |req| 125 | req.headers = @auth.apply(req.headers) 126 | req.headers["Content-Type"] = "application/json" 127 | req.headers["X-Goog-User-Project"] = gcp_project 128 | req.body = body.to_json 129 | req.options.timeout = 15 130 | req.options.open_timeout = 5 131 | end 132 | rescue Faraday::Error => ex 133 | UI.abort_with_message!("Network error when initializing Firebase Test Lab, " \ 134 | "type: #{ex.class}, message: #{ex.message}") 135 | end 136 | 137 | if resp.status != 200 138 | FastlaneCore::UI.error("Failed to start Firebase Test Lab jobs.") 139 | FastlaneCore::UI.abort_with_message!(ErrorHelper.summarize_google_error(resp.body)) 140 | else 141 | response_json = JSON.parse(resp.body) 142 | return response_json["testMatrixId"] 143 | end 144 | end 145 | 146 | def get_matrix_results(gcp_project, matrix_id) 147 | url = FTL_RESULTS_API 148 | .gsub("{project}", gcp_project) 149 | .gsub("{matrix}", matrix_id) 150 | 151 | conn = Faraday.new(FIREBASE_TEST_LAB_ENDPOINT) 152 | begin 153 | resp = conn.get(url) do |req| 154 | req.headers = @auth.apply(req.headers) 155 | req.options.timeout = 15 156 | req.options.open_timeout = 5 157 | end 158 | rescue Faraday::Error => ex 159 | UI.abort_with_message!("Network error when attempting to get test results, " \ 160 | "type: #{ex.class}, message: #{ex.message}") 161 | end 162 | 163 | if resp.status != 200 164 | FastlaneCore::UI.error("Failed to obtain test results.") 165 | FastlaneCore::UI.abort_with_message!(ErrorHelper.summarize_google_error(resp.body)) 166 | return nil 167 | else 168 | return JSON.parse(resp.body) 169 | end 170 | end 171 | 172 | def get_execution_steps(gcp_project, history_id, execution_id) 173 | conn = Faraday.new(APIARY_ENDPOINT) 174 | url = TOOLRESULTS_LIST_EXECUTION_STEP_API_V3 175 | .gsub("{project}", gcp_project) 176 | .gsub("{history_id}", history_id) 177 | .gsub("{execution_id}", execution_id) 178 | begin 179 | resp = conn.get(url) do |req| 180 | req.headers = @auth.apply(req.headers) 181 | req.options.timeout = 15 182 | req.options.open_timeout = 5 183 | end 184 | rescue Faraday::Error => ex 185 | UI.abort_with_message!("Failed to obtain the metadata of test artifacts, " \ 186 | "type: #{ex.class}, message: #{ex.message}") 187 | end 188 | 189 | if resp.status != 200 190 | FastlaneCore::UI.error("Failed to obtain the metadata of test artifacts.") 191 | FastlaneCore::UI.abort_with_message!(ErrorHelper.summarize_google_error(resp.body)) 192 | end 193 | return JSON.parse(resp.body) 194 | end 195 | 196 | def self.map_device_to_proto(device) 197 | { 198 | iosModelId: device[:ios_model_id], 199 | iosVersionId: device[:ios_version_id], 200 | locale: device[:locale], 201 | orientation: device[:orientation] 202 | } 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/ios_validator.rb: -------------------------------------------------------------------------------- 1 | require 'zip' 2 | require 'plist' 3 | 4 | module Fastlane 5 | module FirebaseTestLab 6 | class IosValidator 7 | def self.validate_ios_app(file_path) 8 | absolute_path = File.expand_path(file_path) 9 | begin 10 | Zip::File.open(absolute_path) do |zip_file| 11 | xctestrun_files = zip_file.glob("*.xctestrun") 12 | if xctestrun_files.size != 1 13 | UI.user_error!("app verification failed: There must be only one .xctestrun files in the ZIP file.") 14 | end 15 | 16 | conf = Plist.parse_xml(xctestrun_files.first.get_input_stream) 17 | 18 | # Ensure we only have one scheme (other than the metadata if that's present) 19 | size = conf.size 20 | if conf['__xctestrun_metadata__'] 21 | size -= 1 22 | end 23 | unless size == 1 24 | UI.user_error!("The app bundle may contain only one scheme, #{size} found.") 25 | end 26 | 27 | # Find the tests scheme that's not the metadata scheme 28 | scheme_conf = nil 29 | conf.each do |key, value| 30 | if scheme_conf.nil? && key != '__xctestrun_metadata__' 31 | scheme_conf = value 32 | end 33 | end 34 | 35 | # Ensure we found the tests scheme 36 | if scheme_conf.nil? 37 | UI.user_error!("Failed to find your UI tests scheme in your .xctestrun file.") 38 | end 39 | 40 | unless scheme_conf["IsUITestBundle"] 41 | UI.user_error!("The app bundle is not a UI test bundle. Did you build with build-for-testing argument?") 42 | end 43 | unless scheme_conf.key?("TestHostPath") || scheme_conf.key?("TestBundlePath") 44 | UI.user_error!("Either TestHostPath or TestBundlePath must be in the app bundle. Please check your " \ 45 | "xcodebuild arguments") 46 | end 47 | end 48 | rescue Zip::Error => e 49 | UI.user_error!("Failed to read the ZIP file #{file_path}: #{e.message}") 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/helper/storage.rb: -------------------------------------------------------------------------------- 1 | require 'googleauth' 2 | require 'google/cloud/storage' 3 | 4 | module Fastlane 5 | module FirebaseTestLab 6 | class Storage 7 | GCS_OAUTH_SCOPES = ["https://www.googleapis.com/auth/devstorage.full_control"] 8 | 9 | private_constant :GCS_OAUTH_SCOPES 10 | 11 | def initialize(gcp_project, credential, gcp_requests_timeout) 12 | credentials = credential.get_google_credential(GCS_OAUTH_SCOPES) 13 | @client = Google::Cloud::Storage.new(project_id: gcp_project, 14 | credentials: credentials, 15 | timeout: gcp_requests_timeout) 16 | end 17 | 18 | def upload_file(source_path, destination_bucket, destination_path) 19 | bucket = @client.bucket(destination_bucket) 20 | bucket.create_file(source_path, destination_path) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/module.rb: -------------------------------------------------------------------------------- 1 | module Fastlane 2 | module FirebaseTestLab 3 | VERSION = "1.0.7" 4 | PLUGIN_NAME = "fastlane-plugin-firebase_test_lab" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/fastlane/plugin/firebase_test_lab/options.rb: -------------------------------------------------------------------------------- 1 | require 'fastlane_core/configuration/config_item' 2 | 3 | module Fastlane 4 | module FirebaseTestLab 5 | class Options 6 | def self.available_options 7 | [ 8 | FastlaneCore::ConfigItem.new(key: :gcp_project, 9 | description: "Google Cloud Platform project name", 10 | optional: false), 11 | FastlaneCore::ConfigItem.new(key: :gcp_requests_timeout, 12 | description: "The timeout (in seconds) to use for all Google Cloud requests (such as uploading your tests ZIP)", 13 | optional: true), 14 | FastlaneCore::ConfigItem.new(key: :gcp_additional_client_info, 15 | description: "A hash of additional client info you'd like to submit to Test Lab", 16 | type: Hash, 17 | optional: true), 18 | FastlaneCore::ConfigItem.new(key: :app_path, 19 | description: "Path to the app, either on the filesystem or GCS address (gs://)", 20 | default_value: 21 | Actions.lane_context[Actions::SharedValues::SCAN_ZIP_BUILD_PRODUCTS_PATH], 22 | verify_block: proc do |value| 23 | unless value.to_s.start_with?("gs://") 24 | v = File.expand_path(value.to_s) 25 | UI.user_error!("App file not found at path '#{v}'") unless File.exist?(v) 26 | end 27 | end), 28 | FastlaneCore::ConfigItem.new(key: :devices, 29 | description: "Devices to test the app on", 30 | type: Array, 31 | default_value: [{ 32 | ios_model_id: "iphonex", 33 | ios_version_id: "11.2", 34 | locale: "en_US", 35 | orientation: "portrait" 36 | }], 37 | verify_block: proc do |value| 38 | if value.empty? 39 | UI.user_error!("Devices cannot be empty") 40 | end 41 | value.each do |current| 42 | if current.class != Hash 43 | UI.user_error!("Each device must be represented by a Hash object, " \ 44 | "#{current.class} found") 45 | end 46 | check_has_property(current, :ios_model_id) 47 | check_has_property(current, :ios_version_id) 48 | set_default_property(current, :locale, "en_US") 49 | set_default_property(current, :orientation, "portrait") 50 | end 51 | end), 52 | FastlaneCore::ConfigItem.new(key: :async, 53 | description: "Do not wait for test results", 54 | default_value: false, 55 | type: Fastlane::Boolean), 56 | FastlaneCore::ConfigItem.new(key: :skip_validation, 57 | description: "Do not validate the app before uploading", 58 | default_value: false, 59 | type: Fastlane::Boolean), 60 | FastlaneCore::ConfigItem.new(key: :timeout_sec, 61 | description: "After how long, in seconds, should tests be terminated", 62 | default_value: 180, 63 | optional: true, 64 | type: Integer, 65 | verify_block: proc do |value| 66 | UI.user_error!("Timeout must be less or equal to 45 minutes.") \ 67 | if value <= 0 || value > 45 * 60 68 | end), 69 | FastlaneCore::ConfigItem.new(key: :result_storage, 70 | description: "GCS path to store test results", 71 | default_value: nil, 72 | optional: true, 73 | verify_block: proc do |value| 74 | UI.user_error!("Invalid GCS path: '#{value}'") \ 75 | unless value.to_s.start_with?("gs://") 76 | end), 77 | FastlaneCore::ConfigItem.new(key: :oauth_key_file_path, 78 | description: "Use the given Google cloud service key file." \ 79 | "If not set, application default credential will be used " \ 80 | "(see https://cloud.google.com/docs/authentication/production)", 81 | default_value: nil, 82 | optional: true, 83 | verify_block: proc do |value| 84 | v = File.expand_path(value.to_s) 85 | UI.user_error!("Key file not found at path '#{v}'") unless File.exist?(v) 86 | end) 87 | ] 88 | end 89 | 90 | def self.check_has_property(hash_obj, property) 91 | UI.user_error!("Each device must have #{property} property") unless hash_obj.key?(property) 92 | end 93 | 94 | def self.set_default_property(hash_obj, property, default) 95 | unless hash_obj.key?(property) 96 | hash_obj[property] = default 97 | end 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /testlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FirebaseExtended/fastlane-plugin-firebase_test_lab/eee528b056609d06ce36126012f9636028af6536/testlab.png --------------------------------------------------------------------------------