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.
- checksums.yaml +7 -0
- data/bin/bugsnag-print-load-paths +6 -0
- data/bin/download-logs +76 -0
- data/bin/maze-runner +136 -0
- data/bin/upload-app +56 -0
- data/lib/features/scripts/await-android-emulator.sh +11 -0
- data/lib/features/scripts/clear-android-app-data.sh +8 -0
- data/lib/features/scripts/force-stop-android-app.sh +8 -0
- data/lib/features/scripts/install-android-app.sh +15 -0
- data/lib/features/scripts/launch-android-app.sh +38 -0
- data/lib/features/scripts/launch-android-emulator.sh +15 -0
- data/lib/features/steps/android_steps.rb +51 -0
- data/lib/features/steps/app_automator_steps.rb +228 -0
- data/lib/features/steps/aws_sam_steps.rb +212 -0
- data/lib/features/steps/breadcrumb_steps.rb +50 -0
- data/lib/features/steps/browser_steps.rb +93 -0
- data/lib/features/steps/build_api_steps.rb +25 -0
- data/lib/features/steps/document_server_steps.rb +7 -0
- data/lib/features/steps/error_reporting_steps.rb +342 -0
- data/lib/features/steps/feature_flag_steps.rb +190 -0
- data/lib/features/steps/header_steps.rb +72 -0
- data/lib/features/steps/log_steps.rb +29 -0
- data/lib/features/steps/multipart_request_steps.rb +142 -0
- data/lib/features/steps/network_steps.rb +75 -0
- data/lib/features/steps/payload_steps.rb +234 -0
- data/lib/features/steps/proxy_steps.rb +34 -0
- data/lib/features/steps/query_parameter_steps.rb +31 -0
- data/lib/features/steps/request_assertion_steps.rb +107 -0
- data/lib/features/steps/runner_steps.rb +406 -0
- data/lib/features/steps/session_tracking_steps.rb +116 -0
- data/lib/features/steps/value_steps.rb +119 -0
- data/lib/features/support/env.rb +7 -0
- data/lib/features/support/internal_hooks.rb +260 -0
- data/lib/maze/appium_server.rb +112 -0
- data/lib/maze/assertions/request_set_assertions.rb +97 -0
- data/lib/maze/aws/sam.rb +112 -0
- data/lib/maze/bitbar_devices.rb +84 -0
- data/lib/maze/bitbar_utils.rb +112 -0
- data/lib/maze/browser_stack_devices.rb +160 -0
- data/lib/maze/browser_stack_utils.rb +164 -0
- data/lib/maze/browsers_bs.yml +220 -0
- data/lib/maze/browsers_cbt.yml +100 -0
- data/lib/maze/bugsnag_config.rb +42 -0
- data/lib/maze/capabilities.rb +126 -0
- data/lib/maze/checks/assert_check.rb +91 -0
- data/lib/maze/checks/noop_check.rb +34 -0
- data/lib/maze/compare.rb +161 -0
- data/lib/maze/configuration.rb +174 -0
- data/lib/maze/docker.rb +108 -0
- data/lib/maze/document_server.rb +46 -0
- data/lib/maze/driver/appium.rb +217 -0
- data/lib/maze/driver/browser.rb +138 -0
- data/lib/maze/driver/resilient_appium.rb +51 -0
- data/lib/maze/errors.rb +20 -0
- data/lib/maze/helper.rb +118 -0
- data/lib/maze/hooks/appium_hooks.rb +216 -0
- data/lib/maze/hooks/browser_hooks.rb +68 -0
- data/lib/maze/hooks/command_hooks.rb +9 -0
- data/lib/maze/hooks/hooks.rb +61 -0
- data/lib/maze/interactive_cli.rb +173 -0
- data/lib/maze/logger.rb +73 -0
- data/lib/maze/macos_utils.rb +14 -0
- data/lib/maze/network.rb +49 -0
- data/lib/maze/option/parser.rb +245 -0
- data/lib/maze/option/processor.rb +143 -0
- data/lib/maze/option/validator.rb +184 -0
- data/lib/maze/option.rb +64 -0
- data/lib/maze/plugins/bugsnag_reporting_plugin.rb +49 -0
- data/lib/maze/plugins/cucumber_report_plugin.rb +101 -0
- data/lib/maze/plugins/global_retry_plugin.rb +38 -0
- data/lib/maze/proxy.rb +114 -0
- data/lib/maze/request_list.rb +82 -0
- data/lib/maze/retry_handler.rb +76 -0
- data/lib/maze/runner.rb +149 -0
- data/lib/maze/sauce_labs_utils.rb +96 -0
- data/lib/maze/server.rb +207 -0
- data/lib/maze/servlets/base_servlet.rb +22 -0
- data/lib/maze/servlets/command_servlet.rb +44 -0
- data/lib/maze/servlets/log_servlet.rb +64 -0
- data/lib/maze/servlets/reflective_servlet.rb +69 -0
- data/lib/maze/servlets/servlet.rb +160 -0
- data/lib/maze/smart_bear_utils.rb +71 -0
- data/lib/maze/store.rb +15 -0
- data/lib/maze/terminating_server.rb +129 -0
- data/lib/maze/timers.rb +51 -0
- data/lib/maze/wait.rb +35 -0
- data/lib/maze.rb +27 -0
- 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
|
+
|
data/lib/maze/aws/sam.rb
ADDED
@@ -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
|