├── .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 | [](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
--------------------------------------------------------------------------------