bugsnag-maze-runner 6.27.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/bin/bugsnag-print-load-paths +6 -0
  3. data/bin/download-logs +76 -0
  4. data/bin/maze-runner +136 -0
  5. data/bin/upload-app +56 -0
  6. data/lib/features/scripts/await-android-emulator.sh +11 -0
  7. data/lib/features/scripts/clear-android-app-data.sh +8 -0
  8. data/lib/features/scripts/force-stop-android-app.sh +8 -0
  9. data/lib/features/scripts/install-android-app.sh +15 -0
  10. data/lib/features/scripts/launch-android-app.sh +38 -0
  11. data/lib/features/scripts/launch-android-emulator.sh +15 -0
  12. data/lib/features/steps/android_steps.rb +51 -0
  13. data/lib/features/steps/app_automator_steps.rb +228 -0
  14. data/lib/features/steps/aws_sam_steps.rb +212 -0
  15. data/lib/features/steps/breadcrumb_steps.rb +50 -0
  16. data/lib/features/steps/browser_steps.rb +93 -0
  17. data/lib/features/steps/build_api_steps.rb +25 -0
  18. data/lib/features/steps/document_server_steps.rb +7 -0
  19. data/lib/features/steps/error_reporting_steps.rb +342 -0
  20. data/lib/features/steps/feature_flag_steps.rb +190 -0
  21. data/lib/features/steps/header_steps.rb +72 -0
  22. data/lib/features/steps/log_steps.rb +29 -0
  23. data/lib/features/steps/multipart_request_steps.rb +142 -0
  24. data/lib/features/steps/network_steps.rb +75 -0
  25. data/lib/features/steps/payload_steps.rb +234 -0
  26. data/lib/features/steps/proxy_steps.rb +34 -0
  27. data/lib/features/steps/query_parameter_steps.rb +31 -0
  28. data/lib/features/steps/request_assertion_steps.rb +107 -0
  29. data/lib/features/steps/runner_steps.rb +406 -0
  30. data/lib/features/steps/session_tracking_steps.rb +116 -0
  31. data/lib/features/steps/value_steps.rb +119 -0
  32. data/lib/features/support/env.rb +7 -0
  33. data/lib/features/support/internal_hooks.rb +260 -0
  34. data/lib/maze/appium_server.rb +112 -0
  35. data/lib/maze/assertions/request_set_assertions.rb +97 -0
  36. data/lib/maze/aws/sam.rb +112 -0
  37. data/lib/maze/bitbar_devices.rb +84 -0
  38. data/lib/maze/bitbar_utils.rb +112 -0
  39. data/lib/maze/browser_stack_devices.rb +160 -0
  40. data/lib/maze/browser_stack_utils.rb +164 -0
  41. data/lib/maze/browsers_bs.yml +220 -0
  42. data/lib/maze/browsers_cbt.yml +100 -0
  43. data/lib/maze/bugsnag_config.rb +42 -0
  44. data/lib/maze/capabilities.rb +126 -0
  45. data/lib/maze/checks/assert_check.rb +91 -0
  46. data/lib/maze/checks/noop_check.rb +34 -0
  47. data/lib/maze/compare.rb +161 -0
  48. data/lib/maze/configuration.rb +174 -0
  49. data/lib/maze/docker.rb +108 -0
  50. data/lib/maze/document_server.rb +46 -0
  51. data/lib/maze/driver/appium.rb +217 -0
  52. data/lib/maze/driver/browser.rb +138 -0
  53. data/lib/maze/driver/resilient_appium.rb +51 -0
  54. data/lib/maze/errors.rb +20 -0
  55. data/lib/maze/helper.rb +118 -0
  56. data/lib/maze/hooks/appium_hooks.rb +216 -0
  57. data/lib/maze/hooks/browser_hooks.rb +68 -0
  58. data/lib/maze/hooks/command_hooks.rb +9 -0
  59. data/lib/maze/hooks/hooks.rb +61 -0
  60. data/lib/maze/interactive_cli.rb +173 -0
  61. data/lib/maze/logger.rb +73 -0
  62. data/lib/maze/macos_utils.rb +14 -0
  63. data/lib/maze/network.rb +49 -0
  64. data/lib/maze/option/parser.rb +245 -0
  65. data/lib/maze/option/processor.rb +143 -0
  66. data/lib/maze/option/validator.rb +184 -0
  67. data/lib/maze/option.rb +64 -0
  68. data/lib/maze/plugins/bugsnag_reporting_plugin.rb +49 -0
  69. data/lib/maze/plugins/cucumber_report_plugin.rb +101 -0
  70. data/lib/maze/plugins/global_retry_plugin.rb +38 -0
  71. data/lib/maze/proxy.rb +114 -0
  72. data/lib/maze/request_list.rb +82 -0
  73. data/lib/maze/retry_handler.rb +76 -0
  74. data/lib/maze/runner.rb +149 -0
  75. data/lib/maze/sauce_labs_utils.rb +96 -0
  76. data/lib/maze/server.rb +207 -0
  77. data/lib/maze/servlets/base_servlet.rb +22 -0
  78. data/lib/maze/servlets/command_servlet.rb +44 -0
  79. data/lib/maze/servlets/log_servlet.rb +64 -0
  80. data/lib/maze/servlets/reflective_servlet.rb +69 -0
  81. data/lib/maze/servlets/servlet.rb +160 -0
  82. data/lib/maze/smart_bear_utils.rb +71 -0
  83. data/lib/maze/store.rb +15 -0
  84. data/lib/maze/terminating_server.rb +129 -0
  85. data/lib/maze/timers.rb +51 -0
  86. data/lib/maze/wait.rb +35 -0
  87. data/lib/maze.rb +27 -0
  88. metadata +371 -0
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cucumber'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'securerandom'
7
+ require 'selenium-webdriver'
8
+ require 'uri'
9
+
10
+ BeforeAll do
11
+
12
+ Maze.check = Maze::Checks::AssertCheck.new
13
+
14
+ # Infer mode of operation from config, one of:
15
+ # - Appium (using either remote or local devices)
16
+ # - Browser (Selenium with local or remote browsers)
17
+ # - Command (the software under test is invoked with a system call)
18
+ # TODO Consider making this a specific command line option defaulting to Appium
19
+ is_appium = [:bs, :sl, :bb, :local].include?(Maze.config.farm) && !Maze.config.app.nil?
20
+ is_browser = !Maze.config.browser.nil?
21
+ if is_appium
22
+ Maze.mode = :appium
23
+ Maze.internal_hooks = Maze::Hooks::AppiumHooks.new
24
+ elsif is_browser
25
+ Maze.mode = :browser
26
+ Maze.internal_hooks = Maze::Hooks::BrowserHooks.new
27
+ else
28
+ Maze.mode = :command
29
+ Maze.internal_hooks = Maze::Hooks::CommandHooks.new
30
+ end
31
+ $logger.info "Running in #{Maze.mode.to_s} mode"
32
+
33
+ # Clear out maze_output folder
34
+ maze_output = Dir.glob(File.join(Dir.pwd, 'maze_output', '*'))
35
+ if Maze.config.file_log && !maze_output.empty?
36
+ maze_output.each { |path| $logger.info "Clearing contents of #{path}" }
37
+ FileUtils.rm_rf(maze_output)
38
+ end
39
+
40
+ # Record the local server starting time
41
+ Maze.start_time = Time.now.strftime('%Y-%m-%d %H:%M:%S')
42
+
43
+ # Start document server, if asked for
44
+ Maze::DocumentServer.start unless Maze.config.document_server_root.nil?
45
+
46
+ # Start mock server
47
+ Maze::Server.start
48
+
49
+ # Invoke the internal hook for the mode of operation
50
+ Maze.internal_hooks.before_all
51
+
52
+ # Call any blocks registered by the client
53
+ Maze.hooks.call_before_all
54
+ end
55
+
56
+ # @param config The Cucumber config
57
+ InstallPlugin do |config|
58
+ # Start Bugsnag
59
+ Maze::BugsnagConfig.start_bugsnag(config)
60
+
61
+ # Only add the retry plugin if --retry is not used on the command line
62
+ config.filters << Maze::Plugins::GlobalRetryPlugin.new(config) if config.options[:retry].zero?
63
+ config.filters << Maze::Plugins::BugsnagReportingPlugin.new(config)
64
+ cucumber_report_plugin = Maze::Plugins::CucumberReportPlugin.new
65
+ cucumber_report_plugin.install_plugin(config)
66
+ end
67
+
68
+ # Before each scenario
69
+ Before do |scenario|
70
+ # Default to no dynamic try
71
+ Maze.dynamic_retry = false
72
+
73
+ if ENV['BUILDKITE']
74
+ location = "\e[90m\t# #{scenario.location}\e[0m"
75
+ $stdout.puts "--- Scenario: #{scenario.name} #{location}"
76
+ end
77
+
78
+ # Invoke the internal hook for the mode of operation
79
+ Maze.internal_hooks.before
80
+
81
+ # Call any blocks registered by the client
82
+ Maze.hooks.call_before scenario
83
+ end
84
+
85
+ # General processing to be run after each scenario
86
+ After do |scenario|
87
+ # If we're running on macos, take a screenshot if the scenario fails
88
+ if Maze.config.os == "macos" && scenario.status == :failed
89
+ Maze::MacosUtils.capture_screen(scenario)
90
+ end
91
+
92
+ # Call any blocks registered by the client
93
+ Maze.hooks.call_after scenario
94
+
95
+ # Make sure we reset to HTTP 200 return status after each scenario
96
+ Maze::Server.status_code = 200
97
+ Maze::Server.reset_status_code = false
98
+
99
+ # Similarly for the response delay
100
+ Maze::Server.response_delay_ms = 0
101
+ Maze::Server.reset_response_delay = false
102
+
103
+ # Stop document server if started by the Cucumber step
104
+ Maze::DocumentServer.manual_stop
105
+
106
+ # Stop terminating server if started by the Cucumber step
107
+ Maze::TerminatingServer.stop
108
+
109
+ # This is here to stop sessions from one test hitting another.
110
+ # However this does mean that tests take longer.
111
+ # In addition, reset the last captured exit code
112
+ # TODO:SM We could try and fix this by generating unique endpoints
113
+ # for each test.
114
+ Maze::Docker.reset
115
+
116
+ # Make sure that any scripts are killed between test runs
117
+ # so future tests are run from a clean slate.
118
+ Maze::Runner.kill_running_scripts
119
+
120
+ Maze::Proxy.instance.stop
121
+
122
+ # Log unprocessed requests on Buildkite if the scenario fails
123
+ if (scenario.failed? && Maze.config.log_requests) || Maze.config.always_log
124
+ $stdout.puts '^^^ +++'
125
+ output_received_requests('errors')
126
+ output_received_requests('sessions')
127
+ output_received_requests('builds')
128
+ output_received_requests('logs')
129
+ output_received_requests('invalid requests')
130
+ end
131
+
132
+ # Log all received requests to file
133
+ write_requests(scenario) if Maze.config.file_log
134
+
135
+ # Invoke the internal hook for the mode of operation
136
+ Maze.internal_hooks.after
137
+
138
+ ensure
139
+ # Request arrays in particular are cleared here, rather than in the Before hook, to allow requests to be registered
140
+ # when a test fixture starts (which can be before the first Before scenario hook fires).
141
+ Maze::Server.errors.clear
142
+ Maze::Server.sessions.clear
143
+ Maze::Server.builds.clear
144
+ Maze::Server.uploads.clear
145
+ Maze::Server.sourcemaps.clear
146
+ Maze::Server.logs.clear
147
+ Maze::Server.invalid_requests.clear
148
+ Maze::Runner.environment.clear
149
+ Maze::Store.values.clear
150
+ Maze::Aws::Sam.reset!
151
+ end
152
+
153
+ def output_received_requests(request_type)
154
+ request_queue = Maze::Server.list_for(request_type)
155
+ if request_queue.empty?
156
+ $logger.info "No #{request_type} received"
157
+ else
158
+ count = request_queue.size_all
159
+ $logger.info "#{count} #{request_type} were received:"
160
+ request_queue.all.each.with_index(1) do |request, number|
161
+ $stdout.puts "--- #{request_type} #{number} of #{count}"
162
+ Maze::LogUtil.log_hash(Logger::Severity::INFO, request)
163
+ end
164
+ end
165
+ end
166
+
167
+ # Writes each list of requests to a separate file under, e.g:
168
+ # maze_output/failed/scenario_name/errors.log
169
+ def write_requests(scenario)
170
+ folder1 = File.join(Dir.pwd, 'maze_output')
171
+ folder2 = scenario.failed? ? 'failed' : 'passed'
172
+ folder3 = Maze::Helper.to_friendly_filename(scenario.name)
173
+
174
+ path = File.join(folder1, folder2, folder3)
175
+
176
+ FileUtils.makedirs(path)
177
+
178
+ request_types = %w[errors sessions builds uploads logs sourcemaps invalid]
179
+
180
+ request_types.each do |request_type|
181
+ list = Maze::Server.list_for(request_type).all
182
+ next if list.empty?
183
+
184
+ filename = "#{request_type}.log"
185
+ filepath = File.join(path, filename)
186
+
187
+ counter = 1
188
+ File.open(filepath, 'w+') do |file|
189
+ list.each do |request|
190
+ file.puts "=== Request #{counter} of #{list.size} ==="
191
+ if request[:invalid]
192
+ invalid_request = true
193
+ uri = request[:request][:request_uri]
194
+ headers = request[:request][:header]
195
+ body = request[:request][:body]
196
+ else
197
+ invalid_request = false
198
+ uri = request[:request].request_uri
199
+ headers = request[:request].header
200
+ body = request[:body]
201
+ end
202
+ file.puts "URI: #{uri}"
203
+ file.puts "HEADERS:"
204
+ headers.each do |key, values|
205
+ file.puts " #{key}: #{values.map {|v| "'#{v}'"}.join(' ')}"
206
+ end
207
+ file.puts
208
+ file.puts "BODY:"
209
+ if !invalid_request && headers["content-type"].first == 'application/json'
210
+ file.puts JSON.pretty_generate(body)
211
+ else
212
+ file.puts body
213
+ end
214
+ file.puts
215
+ if request.include?(:reason)
216
+ file.puts "REASON:"
217
+ file.puts request[:reason]
218
+ file.puts
219
+ end
220
+ counter += 1
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ # Check for invalid requests after each scenario. This is its own hook as failing a scenario raises an exception
227
+ # and we need the logic in the other After hook to be performed.
228
+ # Furthermore, this hook should appear after the general hook as they are executed in reverse order by Cucumber.
229
+ After do |scenario|
230
+ unless Maze::Server.invalid_requests.empty?
231
+ msg = "#{Maze::Server.invalid_requests.size_all} invalid request(s) received during scenario"
232
+ scenario.fail msg
233
+ end
234
+ end
235
+
236
+ # After all tests
237
+ AfterAll do
238
+
239
+ if Maze.timers.size.positive?
240
+ $stdout.puts '--- Timer summary'
241
+ Maze.timers.report
242
+ end
243
+
244
+ $stdout.puts '+++ All scenarios complete'
245
+
246
+ # Stop the mock server
247
+ Maze::Server.stop
248
+
249
+ # In order to not impact future test runs, we down
250
+ # all services (which removes networks etc) so that
251
+ # future test runs are from a clean slate.
252
+ Maze::Docker.down_all_services
253
+
254
+ # Invoke the internal hook for the mode of operation
255
+ Maze.internal_hooks.after_all
256
+ end
257
+
258
+ at_exit do
259
+ Maze.internal_hooks.at_exit
260
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'logger'
5
+
6
+ module Maze
7
+ # Basic shell that runs an Appium server on a separate thread
8
+ class AppiumServer
9
+ class << self
10
+ # @return [string|nil] The PID of the appium process (if available)
11
+ attr_reader :pid
12
+
13
+ # @return [thread|nil] The thread running the appium process (if available)
14
+ attr_reader :appium_thread
15
+
16
+ # @return [Logger|nil] The logger used for creating the log file
17
+ attr_reader :appium_logger
18
+
19
+ # Starts a separate thread running the appium server so long as:
20
+ # - An instance of the appium server isn't already running
21
+ # - The port configured is available
22
+ # - The appium command is available via CLI
23
+ #
24
+ # @param address [String] The IP address on which to start the appium server
25
+ # @param port [String] The port on which to start the appium server
26
+ def start(address: '0.0.0.0', port: '4723')
27
+ return if running
28
+
29
+ # Check if the appium server appears to be running already, warning and carrying on if so
30
+ unless appium_port_available?(port)
31
+ $logger.warn "Requested appium port:#{port} is in use. Aborting built-in appium server launch"
32
+ return
33
+ end
34
+
35
+ # Check if appium is installed, warning if not
36
+ unless appium_available?
37
+ $logger.warn 'Appium is unavailable to be started from the command line. Install using `npm i -g appium`'
38
+ return
39
+ end
40
+
41
+ start_logger
42
+
43
+ command = "appium -a #{address} -p #{port}"
44
+ @appium_thread = Thread.new do
45
+ PTY.spawn(command) do |stdout, _stdin, pid|
46
+ @pid = pid
47
+ $logger.debug("Appium:#{@pid}") { 'Appium server started' }
48
+ stdout.each do |line|
49
+ log_line(line)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Temporary sleep to allow appium to start
55
+ sleep 2
56
+ end
57
+
58
+ # Checks whether the server is running, as indicated by the @pid and the appium thread being alive
59
+ #
60
+ # @return [Boolean] Whether the local appium server is running
61
+ def running
62
+ @appium_thread&.alive? ? true : false
63
+ end
64
+
65
+ # Stops the appium server, if running, using SIGINT for correct shutdown
66
+ def stop
67
+ return unless running
68
+
69
+ $logger.debug("Appium:#{@pid}") { 'Stopping appium server' }
70
+ Process.kill('INT', @pid)
71
+ @pid = nil
72
+ @appium_thread.join
73
+ @appium_thread = nil
74
+ end
75
+
76
+ private
77
+
78
+ # Checks if the `appium` command is available on CI
79
+ #
80
+ # @return [Boolean] Whether the appium command is available
81
+ def appium_available?
82
+ `appium -v`
83
+ true
84
+ rescue Errno::ENOENT
85
+ false
86
+ end
87
+
88
+ # Starts the logger targeting a file defined by the APPIUM_LOGFILE config option
89
+ def start_logger
90
+ @appium_logger = ::Logger.new(Maze.config.appium_logfile)
91
+ @appium_logger.datetime_format = '%Y-%m-%d %H:%M:%S'
92
+ end
93
+
94
+ # Logs to a known file, creating the outstream if it isn't already present
95
+ #
96
+ # @param line [String] The line to log
97
+ def log_line(line)
98
+ return if @appium_logger.nil?
99
+ @appium_logger.info("Appium:#{@pid}") { line }
100
+ end
101
+
102
+ # Checks if the given port is already in use
103
+ #
104
+ # @param port [String] The port that should be available
105
+ #
106
+ # @return [Boolean] Whether something is running on the given port
107
+ def appium_port_available?(port)
108
+ `netstat -vanp tcp | awk '{ print $4 }' | grep "\.#{port}$"`.empty?
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test/unit'
4
+ require_relative '../helper'
5
+
6
+ module Maze
7
+ module Assertions
8
+ # Provides helper routines for checking sets of requests against values in a table.
9
+ class RequestSetAssertions
10
+ class << self
11
+
12
+ # Checks that a set of requests satisfy the properties expressed by the table given.
13
+ #
14
+ # @param requests [Hash[]] Requests to check
15
+ # @param table [Cucumber::MultilineArgument::DataTable] Table of expected values, where:
16
+ # - headings can be provided as key paths (e.g. events.0.breadcrumbs.0.name)
17
+ # - table values can be written as "null" for nil
18
+ def assert_requests_match(requests, table)
19
+ Maze.check.equal(table.hashes.length,
20
+ requests.length,
21
+ 'Number of requests do not match number of entries in table.')
22
+ matches = matching_rows requests, table
23
+ return if matches.length == table.hashes.length
24
+
25
+ # Not all matched - log diagnostic before failing assertion
26
+ $logger.error "Only #{matches.length} of #{requests.length} matched:"
27
+ $logger.info matches.keys.sort
28
+ matches.sort.to_h.each do |row, request|
29
+ $logger.info "#{table.rows[row]} matched by request element #{matches[request]}"
30
+ end
31
+ Maze.check.equal(requests.length, matches.length, 'Not all requests matched a row in the table.')
32
+ end
33
+
34
+ # Given arrays of requests and table-based criteria, determines which rows of the table
35
+ # are satisfied by one of the requests. Where multiple rows in the table specify the same
36
+ # criteria, there must be multiple requests provided to satisfy each.
37
+ #
38
+ # @param requests [Hash[]] Requests to check
39
+ # @param table [Cucumber::MultilineArgument::DataTable] Table of expected values, where:
40
+ # - headings can be provided as key paths (e.g. events.0.breadcrumbs.0.name)
41
+ # - table values can be written as "null" for nil
42
+ # @return [Hash] A hash of row to request indexes, indicating the first request matching each row.
43
+ # E.g. {0 => 2} means that the first row was satisfied by the 3rd request.
44
+ def matching_rows(requests, table)
45
+
46
+ # iterate through each row in the table. exactly 1 request should match each row.
47
+ row_to_request_matches = {}
48
+ table.hashes.each_with_index do |row, row_index|
49
+ requests.each_with_index do |request, request_index|
50
+ # Skip if row already matched
51
+ next if row_to_request_matches.values.include? request_index
52
+ # Skip if no body in this request
53
+ next unless request.key?(:body)
54
+ next unless request_matches_row(request[:body], row)
55
+
56
+ # Record the match
57
+ row_to_request_matches[row_index] = request_index
58
+ end
59
+ end
60
+ row_to_request_matches
61
+ end
62
+
63
+ # Determines if a request body satisfies the criteria expressed by a row.
64
+ # The special string "null" is interpreted as nil in comparisons and
65
+ # regular expressions are assumed is the start and end of the string is a '/'.
66
+ #
67
+ # @param body [Hash] Request body to consider
68
+ # @param row [Hash] Hash of keys to expected value, where the keys given can
69
+ # be a Mongo-style dot notation path.
70
+ def request_matches_row(body, row)
71
+ row.each do |key, expected_value|
72
+ obs_val = Maze::Helper.read_key_path(body, key)
73
+ next if ('null'.eql? expected_value) && obs_val.nil? # Both are null/nil
74
+ next if ('@not_null'.eql? expected_value) && !obs_val.nil? # The value isn't null
75
+
76
+ unless obs_val.nil?
77
+ if expected_value[0] == '/' && expected_value[-1] == '/'
78
+ # Treat as regexp
79
+ regex = Regexp.new expected_value[1, expected_value.length - 2]
80
+ next if regex.match? obs_val.to_s # Value matches regex
81
+ elsif expected_value.eql? obs_val.to_s
82
+ # Values match
83
+ next
84
+ end
85
+ end
86
+
87
+ # Match not found - return false
88
+ return false
89
+ end
90
+ # All matched - return true
91
+ true
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+
6
+ module Maze
7
+ module Aws
8
+ # Interacts with the AWS SAM CLI to invoke Lambda functions
9
+ # Note that the SAM CLI must be installed on the host machine as it does not
10
+ # run in a Docker container! For this reason the "start-api" command is not
11
+ # supported as it could easily cause port clashes and zombie processes
12
+ class Sam
13
+ class << self
14
+ attr_reader :last_response, :last_exit_code
15
+
16
+ # Invoke the given lambda with an optional event
17
+ #
18
+ # This happens synchronously so there is no need to wait for a response
19
+ #
20
+ # @param directory [String] The directory containing the lambda
21
+ # @param lambda [String] The name of the lambda to invoke
22
+ # @param event [String, nil] An optional event file to invoke with
23
+ #
24
+ # @return [void]
25
+ def invoke(directory, lambda, event = nil)
26
+ command = build_invoke_command(lambda, event)
27
+
28
+ output, @last_exit_code = Maze::Runner.run_command("cd #{directory} && #{command}")
29
+
30
+ @last_response = parse(output)
31
+ end
32
+
33
+ # Reset the last response and last exit code
34
+ #
35
+ # @return [void]
36
+ def reset!
37
+ @last_response = nil
38
+ @last_exit_code = nil
39
+ end
40
+
41
+ private
42
+
43
+ # Build the command to invoke the given lambda with the given event
44
+ #
45
+ # @param lambda [String] The name of the lambda to invoke
46
+ # @param event [String, nil] An optional event file to invoke with
47
+ #
48
+ # @return [String]
49
+ def build_invoke_command(lambda, event)
50
+ command = "sam local invoke #{Shellwords.escape(lambda)}"
51
+ command += " --event #{Shellwords.escape(event)}" unless event.nil?
52
+ command += " --docker-network #{Shellwords.escape(ENV['NETWORK_NAME'])}" if ENV.key?('NETWORK_NAME')
53
+
54
+ command
55
+ end
56
+
57
+ # The command output contains all stdout/stderr lines in an array. The
58
+ # Lambda response is the last line of output as JSON. The response body is
59
+ # also JSON, so we have to parse twice to get a Hash from the output
60
+ #
61
+ # @param output [Array<String>] The command's output as an array of lines
62
+ #
63
+ # @return [Hash]
64
+ def parse(output)
65
+ unless valid?(output)
66
+ raise <<~ERROR
67
+ Unable to parse Lambda output!
68
+ The likely cause is:
69
+ > #{output.last.chomp}
70
+
71
+ Full output:
72
+ > #{output.map(&:chomp).join("\n > ")}
73
+ ERROR
74
+ end
75
+
76
+ # Attempt to parse the last line of output as this is where a JSON
77
+ # response would be. It's possible for a Lambda to output nothing,
78
+ # e.g. if it forcefully exited, so we allow JSON parse failures here
79
+ begin
80
+ parsed_output = JSON.parse(output.last)
81
+ rescue JSON::ParserError
82
+ return {}
83
+ end
84
+
85
+ # Error output has no "body" of additional JSON so we can stop here
86
+ return parsed_output unless parsed_output.key?('body')
87
+
88
+ # The body is _usually_ JSON but doesn't have to be. We attempt to
89
+ # parse it anyway because it allows us to assert against it easily,
90
+ # but if this fails then it may just be in another format, e.g. HTML
91
+ begin
92
+ parsed_output['body'] = JSON.parse(parsed_output['body'])
93
+ rescue JSON::ParserError
94
+ # Ignore
95
+ end
96
+
97
+ parsed_output
98
+ end
99
+
100
+ # Check if the output looks valid. There should be a "END" marker with a
101
+ # request ID if the lambda invocation completed successfully
102
+ #
103
+ # @param output [Array<String>] The command's output as an array of lines
104
+ #
105
+ # @return [Boolean]
106
+ def valid?(output)
107
+ output.any? { |line| line =~ /^END RequestId:/ }
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module Maze
5
+ # Provides a source of capabilities used to run tests against specific BitBar devices
6
+ # noinspection RubyStringKeysInHashInspection
7
+ class BitBarDevices
8
+ APPIUM_1_9_1 = '1.9.1'
9
+ APPIUM_1_15_0 = '1.15.0'
10
+ APPIUM_1_20_2 = '1.20.2'
11
+
12
+ BASE_URI = 'https://cloud.bitbar.com/api/v2/me'
13
+ FILTER_PATH = 'devices/filters'
14
+
15
+ DEVICE_GROUP_IDS = {
16
+ # Classic, non-specific devices for each Android version
17
+ 'ANDROID_10_0' => '46024',
18
+
19
+ # iOS devices
20
+ 'IOS_14' => '46025'
21
+ }
22
+
23
+ class << self
24
+ def call_bitbar_api(path, query, api_key)
25
+ encoded_query = URI.encode_www_form(query)
26
+ uri = URI("#{BASE_URI}/#{path}?#{encoded_query}")
27
+ request = Net::HTTP::Get.new(uri)
28
+ request.basic_auth(api_key, '')
29
+
30
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
31
+ http.request(request)
32
+ end
33
+
34
+ JSON.parse(res.body)
35
+ end
36
+
37
+ def get_filtered_device_name(device_group_id, api_key)
38
+ path = "device-groups/#{device_group_id}/devices"
39
+ query = {
40
+ 'filter': "online_eq_true"
41
+ }
42
+ all_devices = call_bitbar_api(path, query, api_key)
43
+ filtered_devices = all_devices['data'].reject { |device| device['locked'] }
44
+ filtered_devices.first['displayName']
45
+ end
46
+
47
+ def get_device(device_group, platform, platform_version, api_key)
48
+ device_group_id = DEVICE_GROUP_IDS[device_group]
49
+ device_name = get_filtered_device_name(device_group_id, api_key)
50
+ case platform.downcase
51
+ when 'android'
52
+ automation_name = 'UiAutomator1' if platform_version.start_with?('5')
53
+ make_android_hash(device_name, nil, automation_name)
54
+ when 'ios'
55
+ make_ios_hash(device_name)
56
+ else
57
+ throw "Invalid device platform specified #{platform}"
58
+ end
59
+ end
60
+
61
+ def make_android_hash(device, appium_version = nil, automation_name = nil)
62
+ hash = {
63
+ 'platformName' => 'Android',
64
+ 'bitbar_device' => device,
65
+ 'bitbar_target' => 'android',
66
+ 'deviceName' => 'Android Phone'
67
+ }
68
+ hash['bitbar_appiumVersion'] = appium_version if appium_version
69
+ hash['automationName'] = automation_name if automation_name
70
+ hash.freeze
71
+ end
72
+
73
+ def make_ios_hash(device)
74
+ {
75
+ 'platformName' => 'iOS',
76
+ 'bitbar_device' => device,
77
+ 'bitbar_target' => 'ios',
78
+ 'deviceName' => 'iPhone device',
79
+ 'automationName' => 'XCUITest'
80
+ }.freeze
81
+ end
82
+ end
83
+ end
84
+ end