-
Notifications
You must be signed in to change notification settings - Fork 8
Add Firebase Test Lab Support #355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bd7d602
f866e66
848c0ae
928613f
c52bcff
e49b930
66f3e65
372ad20
0deed47
77e180f
5ac5225
c659f02
23870c0
7ac5002
9dc580e
ff89bd0
d7b73c5
c6a8f2c
8e16c7b
14eed21
bd7395e
3267537
b99126a
1c1a8bc
8ab2031
9ba5d34
5d15b3a
85b0df0
5b46778
fdb177c
839be18
f1e3128
d9536de
08bc2c0
7826e23
1b789ce
46e3825
913578a
20ae178
febebef
75a8d41
8e7c274
fb136d0
6db3296
a660c60
495b038
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| require 'securerandom' | ||
|
|
||
| module Fastlane | ||
| module Actions | ||
| module SharedValues | ||
| FIREBASE_TEST_RESULT = :FIREBASE_TEST_LOG_FILE | ||
| FIREBASE_TEST_LOG_FILE_PATH = :FIREBASE_TEST_LOG_FILE_PATH | ||
| end | ||
|
|
||
| class AndroidFirebaseTestAction < Action | ||
| def self.run(params) | ||
| validate_options(params) | ||
|
|
||
| UI.user_error!('You must be logged in to Firebase prior to calling this action. Use the `FirebaseLogin` Action to log in if needed') unless Fastlane::FirebaseAccount.authenticated? | ||
|
|
||
| # Log in to Firebase (and validate credentials) | ||
| run_uuid = params[:test_run_id] || SecureRandom.uuid | ||
| test_dir = params[:results_output_dir] || File.join(Dir.tmpdir(), run_uuid) | ||
|
|
||
| # Set up the log file and output directory | ||
| FileUtils.mkdir_p(test_dir) | ||
| Fastlane::Actions.lane_context[:FIREBASE_TEST_LOG_FILE_PATH] = File.join(test_dir, 'output.log') | ||
|
|
||
| device = Fastlane::FirebaseDevice.new( | ||
| model: params[:model], | ||
| version: params[:version], | ||
| locale: params[:locale], | ||
| orientation: params[:orientation] | ||
| ) | ||
|
|
||
| result = FirebaseTestRunner.run_tests( | ||
| project_id: params[:project_id], | ||
| apk_path: params[:apk_path], | ||
| test_apk_path: params[:test_apk_path], | ||
| device: device, | ||
| type: params[:type] | ||
| ) | ||
|
|
||
| # Download all of the outputs from the job to the local machine | ||
| FirebaseTestRunner.download_result_files( | ||
| result: result, | ||
| destination: test_dir, | ||
| project_id: params[:project_id], | ||
| key_file_path: params[:key_file] | ||
| ) | ||
|
|
||
| FastlaneCore::UI.test_failure! "Firebase Tests failed – more information can be found at #{result.more_details_url}" unless result.success? | ||
|
|
||
| UI.success 'Firebase Tests Complete' | ||
| end | ||
|
|
||
| # Fastlane doesn't eagerly validate options for us, so we'll do it first to have control over | ||
| # when they're evalutated. | ||
| def self.validate_options(params) | ||
| available_options | ||
| .reject { |opt| opt.optional || !opt.default_value.nil? } | ||
| .map(&:key) | ||
| .each { |k| params[k] } | ||
| end | ||
|
|
||
| ##################################################### | ||
| # @!group Documentation | ||
| ##################################################### | ||
|
|
||
| def self.description | ||
| 'Runs the specified tests in Firebase Test Lab' | ||
| end | ||
|
|
||
| def self.details | ||
| description | ||
| end | ||
|
|
||
| def self.available_options | ||
| [ | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :project_id, | ||
| # `env_name` comes from the Google Cloud default: https://cloud.google.com/functions/docs/configuring/env-var | ||
| env_name: 'GCP_PROJECT', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm assuming there is a rationale behind choosing that specific If that's the rationale (this env var name being some standard used by other stuff as well), then you should probably add a If it's not (i.e. it's just that the client app you want to test this action with first happens to have that env var set, but for legacy reasons with some old tooling which won't be relevant anymore, and thus no hard reason to keep that name), you might as well change this to try to follow our usual naming conventions of the env var (even if that means that you'll explicitly pass
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in d9536de |
||
| description: 'The Project ID to test in', | ||
| type: String | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :key_file, | ||
| description: 'The key file used to authorize with Google Cloud', | ||
| type: String, | ||
| verify_block: proc do |value| | ||
| UI.user_error!('The `:key_file` parameter is required') if value.empty? | ||
| UI.user_error!("No Google Cloud Key file found at: #{value}") unless File.exist?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :apk_path, | ||
| description: 'Path to the application APK on the local machine', | ||
| type: String, | ||
| verify_block: proc do |value| | ||
| UI.user_error!('The `:apk_path` parameter is required') if value.empty? | ||
| UI.user_error!("Invalid application APK: #{value}") unless File.exist?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :test_apk_path, | ||
| description: 'Path to the test bundle APK on the local machine', | ||
| type: String, | ||
| verify_block: proc do |value| | ||
| UI.user_error!('The `:test_apk_path` parameter is required') if value.empty? | ||
| UI.user_error!("Invalid test APK: #{value}") unless File.exist?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :model, | ||
| description: 'The device model to use to run the test', | ||
| type: String, | ||
| verify_block: proc do |value| | ||
| UI.user_error!('The `:model` parameter is required') if value.empty? | ||
| FirebaseTestRunner.verify_has_gcloud_binary! | ||
| model_names = Fastlane::FirebaseDevice.valid_model_names | ||
| UI.user_error!("Invalid Model Name: #{value}. Valid Model Names: #{model_names}") unless model_names.include?(value) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The user_error doesn't seem to show (and thus list the available models) when run on CI and we pass an invalid model — see example here. It just says the parameter has been passed invalid value but the message above doesn't show on the logs, so that is not super helpful 😒 |
||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :version, | ||
| description: 'The Android version (API Level) to use to run the test', | ||
| type: Integer, | ||
| verify_block: proc do |value| | ||
| FirebaseTestRunner.verify_has_gcloud_binary! | ||
| version_numbers = Fastlane::FirebaseDevice.valid_version_numbers | ||
| UI.user_error!("Invalid Version Number: #{value}. Valid Version Numbers: #{version_numbers}") unless version_numbers.include?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :locale, | ||
| description: 'The locale code to use when running the test', | ||
| type: String, | ||
| default_value: 'en', | ||
| verify_block: proc do |value| | ||
| FirebaseTestRunner.verify_has_gcloud_binary! | ||
| locale_codes = Fastlane::FirebaseDevice.valid_locales | ||
| UI.user_error!("Invalid Locale: #{value}. Valid Locales: #{locale_codes}") unless locale_codes.include?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :orientation, | ||
| description: 'Which orientation to run the device in', | ||
| type: String, | ||
| default_value: 'portrait', | ||
| verify_block: proc do |value| | ||
| orientations = Fastlane::FirebaseDevice.valid_orientations | ||
| UI.user_error!("Invalid Orientation: #{value}. Valid Orientations: #{orientations}") unless orientations.include?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :type, | ||
| description: 'The type of test to run (e.g. `instrumentation` or `robo`)', | ||
| type: String, | ||
| default_value: 'instrumentation', | ||
| verify_block: proc do |value| | ||
| types = Fastlane::FirebaseTestRunner::VALID_TEST_TYPES | ||
| UI.user_error!("Invalid Test Type: #{value}. Valid Types: #{types}") unless types.include?(value) | ||
| end | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :test_run_id, | ||
| description: 'A unique ID used to identify this test run', | ||
| default_value_dynamic: true, | ||
| optional: true, | ||
| type: String | ||
| ), | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :results_output_dir, | ||
| description: 'The path to the folder where we will store the results of this test run', | ||
| default_value_dynamic: true, | ||
| optional: true, | ||
| type: String | ||
| ), | ||
| ] | ||
| end | ||
|
|
||
| def self.authors | ||
| ['Automattic'] | ||
| end | ||
|
|
||
| def self.is_supported?(platform) | ||
| platform == :android | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| require 'securerandom' | ||
|
|
||
| module Fastlane | ||
| module Actions | ||
| class FirebaseLoginAction < Action | ||
| def self.run(params) | ||
| Fastlane::FirebaseAccount.activate_service_account_with_key_file(params[:key_file]) | ||
| end | ||
|
|
||
| ##################################################### | ||
| # @!group Documentation | ||
| ##################################################### | ||
| def self.description | ||
| 'Logs the local machine into Google Cloud using the provided key file' | ||
| end | ||
|
|
||
| def self.details | ||
| description | ||
| end | ||
|
|
||
| def self.available_options | ||
| [ | ||
| FastlaneCore::ConfigItem.new( | ||
| key: :key_file, | ||
| description: 'The key file used to authorize with Google Cloud', | ||
| type: String, | ||
| verify_block: proc do |value| | ||
| UI.user_error!('The `:key_file` parameter is required') if value.empty? | ||
| UI.user_error!("No Google Cloud Key file found at: #{value}") unless File.exist?(value) | ||
| end | ||
| ), | ||
| ] | ||
| end | ||
|
|
||
| def self.authors | ||
| ['Automattic'] | ||
| end | ||
|
|
||
| def self.is_supported?(platform) | ||
| true | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| module Fastlane | ||
| class FirebaseAccount | ||
| def self.activate_service_account_with_key_file(key_file_path) | ||
| Fastlane::Actions.sh('gcloud', 'auth', 'activate-service-account', '--key-file', key_file_path) | ||
| end | ||
|
|
||
| def self.authenticated? | ||
| auth_status = JSON.parse(auth_status_data) | ||
| auth_status.any? do |account| | ||
| account['status'] == 'ACTIVE' | ||
| end | ||
| end | ||
|
|
||
| # Lookup the current account authentication status | ||
| def self.auth_status_data | ||
| Fastlane::Actions.sh('gcloud', 'auth', 'list', '--format', 'json', log: false) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| module Fastlane | ||
| class FirebaseDevice | ||
| attr_reader :model, :version, :locale, :orientation | ||
|
Comment on lines
+1
to
+3
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 I really like that this was represented as a model class. We don't have many model classes in our I'm not a fan of this being defined directly at the root of the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I worried about this for a moment as well – IMHO if that happens at some point we can address it then? I didn't want to bury it too deeply since it's a pain to reference a deeply-nested model object?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I think we could also keep the idea in the back of our head for later to introduce a I worried about it for this old WIP as well, which might be even more at risk of clashing with something provided by fastlane given its more generic name… so maybe at that point create that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah exactly – this was what had me thinking it'd make sense to leave it for the moment 🙂
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing we could consider at least doing though: put all the Firebase-related models under a Since those are models that all go together hand in hand that would make it clear and neat, and would make us ready for if we add more, unrelated model files for other unrelated things in the future, avoinding to put everything in the same bowl.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that:
|
||
|
|
||
| def initialize(model:, version:, orientation:, locale: 'en') | ||
| raise 'Invalid Model' unless FirebaseDevice.valid_model_names.include? model | ||
| raise 'Invalid Version' unless FirebaseDevice.valid_version_numbers.include? version | ||
| raise 'Invalid Locale' unless FirebaseDevice.valid_locales.include? locale | ||
| raise 'Invalid Orientation' unless FirebaseDevice.valid_orientations.include? orientation | ||
|
|
||
| @model = model | ||
| @version = version | ||
| @locale = locale | ||
| @orientation = orientation | ||
| end | ||
|
|
||
| def to_s | ||
| "model=#{@model},version=#{@version},locale=#{@locale},orientation=#{@orientation}" | ||
| end | ||
|
|
||
| class << self | ||
| @locale_data = nil | ||
| @model_data = nil | ||
| @version_data = nil | ||
|
|
||
| def valid_model_names | ||
| JSON.parse(model_data).map { |device| device['codename'] } | ||
| end | ||
|
|
||
| def valid_version_numbers | ||
| JSON.parse(version_data).map { |version| version['apiLevel'].to_i } | ||
| end | ||
|
|
||
| def valid_locales | ||
| JSON.parse(locale_data).map { |locale| locale['id'] } | ||
| end | ||
|
|
||
| def valid_orientations | ||
| %w[portrait landscape] | ||
| end | ||
|
|
||
| def locale_data | ||
| FirebaseDevice.verify_logged_in! | ||
| @locale_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'locales', 'list', '--format="json"', log: false) | ||
| end | ||
|
|
||
| def model_data | ||
| FirebaseDevice.verify_logged_in! | ||
| @model_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'models', 'list', '--format="json"', log: false) | ||
| end | ||
|
|
||
| def version_data | ||
| FirebaseDevice.verify_logged_in! | ||
| @version_data ||= Fastlane::Actions.sh('gcloud', 'firebase', 'test', 'android', 'versions', 'list', '--format="json"', log: false) | ||
| end | ||
|
|
||
| def verify_logged_in! | ||
| UI.user_error!('You must call `firebase_login` before creating a FirebaseDevice object') unless FirebaseAccount.authenticated? | ||
| end | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍