bugsnag-maze-runner 6.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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