bugsnag-maze-runner 6.27.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|