ruby_raider 3.10.0 → 3.12.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/reek.yml +5 -1
  3. data/.github/workflows/release.yml +2 -1
  4. data/.github/workflows/system_tests.yml +1 -1
  5. data/.reek.yml +13 -0
  6. data/README.md +44 -4
  7. data/lib/commands/utility_commands.rb +1 -1
  8. data/lib/generators/automation/automation_generator.rb +15 -10
  9. data/lib/generators/automation/templates/appium_caps.tt +2 -4
  10. data/lib/generators/automation/templates/home.tt +1 -2
  11. data/lib/generators/automation/templates/login.tt +6 -6
  12. data/lib/generators/automation/templates/page.tt +10 -3
  13. data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
  14. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
  15. data/lib/generators/automation/templates/pdp.tt +3 -2
  16. data/lib/generators/cucumber/cucumber_generator.rb +15 -2
  17. data/lib/generators/cucumber/templates/feature.tt +5 -0
  18. data/lib/generators/cucumber/templates/partials/driver_world.tt +9 -0
  19. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +1 -1
  20. data/lib/generators/cucumber/templates/partials/selenium_env.tt +20 -0
  21. data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
  22. data/lib/generators/cucumber/templates/partials/web_steps.tt +8 -0
  23. data/lib/generators/generator.rb +15 -4
  24. data/lib/generators/helper_generator.rb +15 -0
  25. data/lib/generators/invoke_generators.rb +23 -0
  26. data/lib/generators/menu_generator.rb +26 -14
  27. data/lib/generators/rspec/rspec_generator.rb +10 -3
  28. data/lib/generators/rspec/templates/spec.tt +27 -1
  29. data/lib/generators/template_renderer/template_error.rb +8 -8
  30. data/lib/generators/templates/common/git_ignore.tt +4 -1
  31. data/lib/generators/templates/common/partials/mobile_config.tt +1 -0
  32. data/lib/generators/templates/common/partials/web_config.tt +5 -2
  33. data/lib/generators/templates/common/read_me.tt +508 -0
  34. data/lib/generators/templates/helpers/partials/selenium_driver.tt +1 -3
  35. data/lib/generators/templates/helpers/raider_element.tt +159 -0
  36. data/lib/generators/templates/helpers/session_state.tt +246 -0
  37. data/lib/generators/templates/helpers/spec_helper.tt +28 -0
  38. data/lib/generators/templates/helpers/wait_helper.tt +470 -0
  39. data/lib/ruby_raider/ffmpeg.rb +7 -7
  40. data/lib/ruby_raider.rb +2 -0
  41. data/lib/scaffolding/project_detector.rb +6 -0
  42. data/lib/scaffolding/scaffolding.rb +4 -0
  43. data/lib/scaffolding/templates/component.tt +4 -0
  44. data/lib/scaffolding/templates/page_object.tt +8 -0
  45. data/lib/version +1 -1
  46. data/ruby_raider.gemspec +1 -0
  47. data/sig/generators/generator.rbs +2 -0
  48. data/sig/scaffolding/project_detector.rbs +1 -0
  49. data/sig/scaffolding/scaffolding.rbs +1 -0
  50. data/spec/commands/raider_commands_spec.rb +24 -0
  51. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +175 -0
  52. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +24 -0
  53. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +39 -0
  54. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +33 -0
  55. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +174 -0
  56. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +39 -0
  57. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +33 -0
  58. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +49 -0
  59. data/spec/integration/commands/scaffolding_commands_spec.rb +49 -0
  60. data/spec/integration/commands/utility_commands_spec.rb +8 -0
  61. data/spec/integration/content/azure_content_spec.rb +4 -0
  62. data/spec/integration/content/ci_content_spec.rb +11 -0
  63. data/spec/integration/content/common_content_spec.rb +27 -0
  64. data/spec/integration/content/config_content_spec.rb +62 -0
  65. data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
  66. data/spec/integration/content/helper_content_spec.rb +111 -0
  67. data/spec/integration/content/page_content_spec.rb +75 -4
  68. data/spec/integration/content/readme_content_spec.rb +149 -0
  69. data/spec/integration/content/syntax_validation_spec.rb +38 -0
  70. data/spec/integration/content/test_content_spec.rb +82 -0
  71. data/spec/integration/end_to_end_spec.rb +73 -28
  72. data/spec/integration/generators/automation_generator_spec.rb +50 -0
  73. data/spec/integration/generators/common_generator_spec.rb +20 -0
  74. data/spec/integration/generators/config_features_spec.rb +2 -2
  75. data/spec/integration/generators/cucumber_generator_spec.rb +36 -0
  76. data/spec/integration/generators/helpers_generator_spec.rb +60 -0
  77. data/spec/integration/generators/invoke_generators_spec.rb +70 -0
  78. data/spec/integration/generators/rspec_generator_spec.rb +20 -0
  79. data/spec/integration/helpers/session_state_spec.rb +474 -0
  80. data/spec/integration/helpers/wait_helper_mobile_spec.rb +281 -0
  81. data/spec/integration/helpers/wait_helper_spec.rb +496 -0
  82. data/spec/integration/helpers/wait_helper_support.rb +183 -0
  83. data/spec/integration/settings_helper.rb +4 -1
  84. data/spec/integration/spec_helper.rb +2 -2
  85. data/spec/menus/menu_generator_spec.rb +64 -2
  86. data/spec/scaffolding/scaffold_project_detector_spec.rb +12 -0
  87. metadata +31 -2
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ <%- if mobile? -%>
4
+ # An element found through WaitHelper. It has the same waiting methods as the
5
+ # mixin, minus the locator, so calls chain:
6
+ # find_element(accessibility_id: 'list').find_element(accessibility_id: 'row').tap
7
+ # find_element(accessibility_id: 'card').swipe('left')
8
+ # Everything else (tag_name, rect, location, ...) goes to the raw Appium
9
+ # element without waiting. APIs that type-check their argument
10
+ # (driver.action, execute_script) need .native.
11
+ <%- else -%>
12
+ # An element found through WaitHelper. It has the same waiting methods as the
13
+ # mixin, minus the locator, so calls chain:
14
+ # find_element(css: '.form').find_element(id: 'email').send_keys('a')
15
+ # Everything else (tag_name, rect, property, ...) goes to the raw Selenium
16
+ # element without waiting. Selenium APIs that type-check their argument
17
+ # (switch_to.frame, Support::Select, driver.action, execute_script) need .native.
18
+ <%- end -%>
19
+ class RaiderElement
20
+ def initialize(driver, lookup)
21
+ @driver = driver
22
+ @lookup = lookup
23
+ end
24
+
25
+ def native
26
+ @lookup.element
27
+ end
28
+
29
+ def find_element(locator = nil, visible: true, timeout: nil, **by)
30
+ waiter(timeout).find_element(child(locator, by), visible)
31
+ end
32
+
33
+ def find_elements(locator = nil, timeout: nil, **by)
34
+ waiter(timeout).find_elements(child(locator, by))
35
+ end
36
+
37
+ <%- if mobile? -%>
38
+ %i[click clear].each do |action|
39
+ define_method(action) do |timeout: nil|
40
+ perform(action, timeout) { |element| element.public_send(action) }
41
+ self
42
+ end
43
+ end
44
+
45
+ # tap is Appium's word for click. With a block it stays Ruby's Object#tap.
46
+ def tap(timeout: nil, &block)
47
+ return super(&block) if block
48
+
49
+ perform(:tap, timeout, &:click)
50
+ self
51
+ end
52
+ <%- else -%>
53
+ %i[click clear submit].each do |action|
54
+ define_method(action) do |timeout: nil|
55
+ perform(action, timeout) { |element| element.public_send(action) }
56
+ self
57
+ end
58
+ end
59
+ <%- end -%>
60
+
61
+ %i[text displayed? enabled? selected?].each do |reader|
62
+ define_method(reader) do |timeout: nil|
63
+ perform(reader, timeout) { |element| element.public_send(reader) }
64
+ end
65
+ end
66
+
67
+ def send_keys(*keys, timeout: nil)
68
+ perform(:send_keys, timeout) { |element| element.send_keys(*keys) }
69
+ self
70
+ end
71
+
72
+ <%- unless mobile? -%>
73
+ def select_by(how, what, timeout: nil)
74
+ perform(:select_by, timeout) { |element| Selenium::WebDriver::Support::Select.new(element).select_by(how, what) }
75
+ self
76
+ end
77
+
78
+ <%- end -%>
79
+ def attribute(name, timeout: nil)
80
+ perform(:attribute, timeout) { |element| element.attribute(name) }
81
+ end
82
+
83
+ <%- if mobile? -%>
84
+ # direction: 'up', 'down', 'left' or 'right'.
85
+ %i[swipe scroll].each do |kind|
86
+ define_method(kind) do |direction, timeout: nil|
87
+ gesture = WaitHelper::Gesture.new(@driver, kind, direction)
88
+ perform(kind, timeout) { |element| gesture.perform(element) }
89
+ self
90
+ end
91
+ end
92
+
93
+ # Scrolls the screen until this element is visible, up to max_scrolls times.
94
+ def scroll_to(direction: 'down', max_scrolls: 10, timeout: nil)
95
+ scroller = WaitHelper::Scroller.new(WaitHelper::Gesture.new(@driver, :scroll, direction), max_scrolls)
96
+ waiter(timeout).perform_action(:scroll_to, @lookup, WaitHelper::Waiter::CHECKS[:scroll_to], between: scroller) { nil }
97
+ self
98
+ end
99
+ <%- else -%>
100
+ def hover(timeout: nil)
101
+ perform(:hover, timeout) { |element| @driver.action.move_to(element).perform }
102
+ self
103
+ end
104
+
105
+ def scroll_into_view(timeout: nil)
106
+ perform(:scroll_into_view, timeout) { |element| @driver.action.scroll_to(element).perform }
107
+ self
108
+ end
109
+
110
+ # target: a RaiderElement, a raw element, or a driver-scoped locator.
111
+ def drag_and_drop(target, timeout: nil)
112
+ drop = WaitHelper::Lookup.for(@driver, target, {})
113
+ waiter(timeout).perform_action(:drag_and_drop, @lookup, WaitHelper::Waiter::CHECKS[:drag_and_drop],
114
+ target: drop) do |source, destination|
115
+ @driver.action.drag_and_drop(source, destination).perform
116
+ end
117
+ self
118
+ end
119
+ <%- end -%>
120
+
121
+ def ==(other)
122
+ native == (other.is_a?(RaiderElement) ? other.native : other)
123
+ end
124
+
125
+ def eql?(other)
126
+ self == other
127
+ end
128
+
129
+ def hash
130
+ native.hash
131
+ end
132
+
133
+ private
134
+
135
+ # WaitHelper::Lookup.for reads this to reuse how a wrapper was found.
136
+ attr_reader :lookup
137
+
138
+ def waiter(timeout)
139
+ WaitHelper::Waiter.new(@driver, timeout: timeout)
140
+ end
141
+
142
+ def child(locator, by)
143
+ WaitHelper::Lookup.for(@driver, locator, by, scope: @lookup)
144
+ end
145
+
146
+ def perform(action, timeout, &block)
147
+ waiter(timeout).perform_action(action, @lookup, WaitHelper::Waiter::CHECKS.fetch(action), &block)
148
+ end
149
+
150
+ def method_missing(name, *args, **options, &block)
151
+ return super unless native.respond_to?(name)
152
+
153
+ native.public_send(name, *args, **options, &block)
154
+ end
155
+
156
+ def respond_to_missing?(name, include_private = false)
157
+ native.respond_to?(name) || super
158
+ end
159
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'uri'
7
+ require 'yaml'
8
+
9
+ # Saves the browser login (cookies, localStorage, sessionStorage) to .raider/auth/<name>.json
10
+ # and restores it into a new browser. The file holds live session tokens: it is git-ignored,
11
+ # written 0600, and its values are never printed.
12
+ #
13
+ # SessionState.new(driver, name: 'user').reuse(valid: -> { account.logged_in? }) { log_in }
14
+ #
15
+ # RAIDER_LOGIN_STATE=off always logs in and never saves; =refresh logs in and saves again.
16
+ # Stale age is login_state.max_age (seconds) in config/config.yml.
17
+ class SessionState
18
+ FORMAT = 1
19
+ DEFAULT_MAX_AGE = 3600
20
+ NAME_PATTERN = /\A[a-z0-9_-]+\z/.freeze
21
+ DIR = File.join('.raider', 'auth')
22
+ READ_STORAGE = 'return Object.assign({}, window[arguments[0]]);'
23
+ WRITE_STORAGE = 'var s = window[arguments[0]]; s.clear(); var d = arguments[1]; ' \
24
+ 'Object.keys(d).forEach(function (k) { s.setItem(k, d[k]); });'
25
+ STORAGES = { 'local_storage' => 'localStorage', 'session_storage' => 'sessionStorage' }.freeze
26
+ COOKIE_KEYS = %i[name value domain path secure http_only same_site].freeze
27
+
28
+ attr_reader :name
29
+
30
+ def initialize(driver, name: 'user')
31
+ raise ArgumentError, "name must match #{NAME_PATTERN.source}" unless name.to_s.match?(NAME_PATTERN)
32
+
33
+ @driver = driver
34
+ @name = name.to_s
35
+ end
36
+
37
+ def path
38
+ File.join(DIR, "#{name}.json")
39
+ end
40
+
41
+ def reuse(valid: nil, &login)
42
+ lock = open_lock unless ENV.fetch('RAIDER_LOGIN_STATE', nil) == 'off'
43
+ lock ? reuse_locked(lock, valid, &login) : login.call
44
+ true
45
+ end
46
+
47
+ def save
48
+ ensure_dir
49
+ visit_origin
50
+ state = { 'format' => FORMAT, 'name' => name, 'origin' => origin, 'saved_at' => Time.now.utc.iso8601,
51
+ 'cookies' => @driver.manage.all_cookies.map { |cookie| dump_cookie(cookie) } }
52
+ STORAGES.each { |key, kind| state[key] = @driver.execute_script(READ_STORAGE, kind) || {} }
53
+ write_atomically(JSON.generate(state))
54
+ path
55
+ end
56
+
57
+ def restore
58
+ state = read_state
59
+ usable?(state) && apply(state)
60
+ rescue StandardError => e
61
+ fall_back("could not restore #{path} (#{e.class})")
62
+ end
63
+
64
+ def clear
65
+ FileUtils.rm_f(path)
66
+ end
67
+
68
+ def fresh?
69
+ state = read_state
70
+ usable?(state) && Time.now - Time.iso8601(state['saved_at']) < max_age
71
+ rescue ArgumentError, TypeError
72
+ false
73
+ end
74
+
75
+ private
76
+
77
+ # Warnings wait until the outcome is known: a peer's save can still rescue a failed restore,
78
+ # and each fallback warns once, only when it really logs in.
79
+ def reuse_locked(lock, valid, &login)
80
+ @reason = @note = nil
81
+ @deferred = true
82
+ reused = reused?(lock, valid)
83
+ @deferred = false
84
+ return warn(@note) if reused && @note
85
+ return if reused
86
+
87
+ warn("[login_state] #{@reason}, logging in again") if @reason
88
+ clear
89
+ reset if @applied
90
+ login.call
91
+ save_after_login
92
+ ensure
93
+ lock.close
94
+ end
95
+
96
+ # Restores under a shared lock, so parallel workers restore at the same time; only a real
97
+ # login takes the exclusive lock. Upgrading drops the shared lock first, so a worker that
98
+ # gets the exclusive lock after another one saved restores that login instead.
99
+ def reused?(lock, valid)
100
+ if ENV.fetch('RAIDER_LOGIN_STATE', nil) == 'refresh'
101
+ lock.flock(File::LOCK_EX)
102
+ return false
103
+ end
104
+ lock.flock(File::LOCK_SH)
105
+ seen = saved_signature
106
+ return true if restored?(valid)
107
+
108
+ lock.flock(File::LOCK_UN) # Windows will not upgrade a held shared lock in place
109
+ lock.flock(File::LOCK_EX)
110
+ saved_signature != seen && restored?(valid)
111
+ end
112
+
113
+ def restored?(valid)
114
+ fresh? && restore && still_valid?(valid)
115
+ end
116
+
117
+ # inode, mtime and size: mtime alone misses a save in the same second on coarse filesystems
118
+ def saved_signature
119
+ stat = File.stat(path)
120
+ [stat.ino, stat.mtime, stat.size]
121
+ rescue SystemCallError
122
+ nil
123
+ end
124
+
125
+ def open_lock
126
+ ensure_dir
127
+ File.open(File.join(DIR, "#{name}.lock"), File::RDWR | File::CREAT, 0o600)
128
+ rescue SystemCallError => e
129
+ fall_back("cannot write #{DIR} (#{e.class}), so nothing is saved")
130
+ end
131
+
132
+ def save_after_login
133
+ save
134
+ rescue StandardError => e
135
+ warn("[login_state] could not save #{path} (#{e.class}), the next run logs in again")
136
+ end
137
+
138
+ def reset
139
+ visit_origin
140
+ @driver.manage.delete_all_cookies
141
+ STORAGES.each_value { |kind| @driver.execute_script(WRITE_STORAGE, kind, {}) }
142
+ rescue StandardError
143
+ nil # a browser that cannot be reset fails the real login on its own
144
+ end
145
+
146
+ def visit_origin
147
+ @driver.navigate.to("#{origin}/") unless origin_of(URI.parse(@driver.current_url.to_s)) == origin
148
+ end
149
+
150
+ def still_valid?(valid)
151
+ valid.nil? || valid.call || fall_back("login restored from #{path} is no longer valid")
152
+ rescue StandardError => e
153
+ fall_back("validity check failed (#{e.class})")
154
+ end
155
+
156
+ def apply(state)
157
+ @driver.navigate.to("#{origin}/")
158
+ @applied = true
159
+ @driver.manage.delete_all_cookies
160
+ live, skipped = state['cookies'].partition { |cookie| live_for_origin?(cookie) }
161
+ live.each { |cookie| add_cookie(load_cookie(cookie)) }
162
+ note("[login_state] #{skipped.size} cookies skipped (expired or another domain) from #{path}") if skipped.any?
163
+ STORAGES.each { |key, kind| @driver.execute_script(WRITE_STORAGE, kind, state[key] || {}) }
164
+ @driver.navigate.refresh
165
+ true
166
+ end
167
+
168
+ def add_cookie(attributes)
169
+ @driver.manage.add_cookie(attributes.dup) # Selenium's Manager rewrites the hash it is given
170
+ rescue StandardError
171
+ raise unless attributes.key?(:same_site)
172
+
173
+ @driver.manage.add_cookie(attributes.except(:same_site))
174
+ end
175
+
176
+ def live_for_origin?(cookie)
177
+ (cookie['expires'].nil? || Time.iso8601(cookie['expires']) > Time.now) && domain_matches?(cookie['domain'])
178
+ end
179
+
180
+ def domain_matches?(domain)
181
+ bare = domain.to_s.delete_prefix('.')
182
+ bare.empty? || base_uri.host == bare || base_uri.host.end_with?(".#{bare}")
183
+ end
184
+
185
+ def dump_cookie(cookie)
186
+ COOKIE_KEYS.to_h { |key| [key.to_s, cookie[key]] }.merge('expires' => cookie[:expires]&.to_time&.utc&.iso8601)
187
+ end
188
+
189
+ def load_cookie(cookie)
190
+ COOKIE_KEYS.to_h { |key| [key, cookie[key.to_s]] }
191
+ .merge(expires: cookie['expires'] && Time.iso8601(cookie['expires'])).compact
192
+ end
193
+
194
+ def read_state
195
+ JSON.parse(File.read(path)) if File.exist?(path)
196
+ rescue JSON::ParserError, SystemCallError
197
+ fall_back("#{path} is unreadable or corrupt")
198
+ end
199
+
200
+ def usable?(state)
201
+ return false unless state
202
+ return state['origin'] == origin if state.is_a?(Hash) && state['format'] == FORMAT
203
+
204
+ fall_back("#{path} has an unsupported format")
205
+ end
206
+
207
+ def write_atomically(json)
208
+ temp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
209
+ File.write(temp, json, perm: 0o600)
210
+ File.rename(temp, path)
211
+ end
212
+
213
+ def ensure_dir
214
+ FileUtils.chmod(0o700, FileUtils.mkdir_p(DIR))
215
+ end
216
+
217
+ def config
218
+ @config ||= YAML.load_file('config/config.yml') || {}
219
+ end
220
+
221
+ def max_age
222
+ Integer(config.dig('login_state', 'max_age') || DEFAULT_MAX_AGE)
223
+ end
224
+
225
+ def base_uri
226
+ @base_uri ||= URI.parse(config.fetch('url'))
227
+ end
228
+
229
+ def origin
230
+ @origin ||= origin_of(base_uri)
231
+ end
232
+
233
+ def origin_of(uri)
234
+ port = uri.port == uri.default_port ? '' : ":#{uri.port}"
235
+ "#{uri.scheme}://#{uri.host}#{port}"
236
+ end
237
+
238
+ def fall_back(reason)
239
+ @deferred ? (@reason ||= reason) : warn("[login_state] #{reason}, logging in again")
240
+ false
241
+ end
242
+
243
+ def note(message)
244
+ @deferred ? @note = message : warn(message)
245
+ end
246
+ end
@@ -13,6 +13,17 @@ require_relative 'browser_helper'
13
13
  require_relative 'driver_helper'
14
14
  <%- end -%>
15
15
  require_relative 'video_helper'
16
+ <%- if wait_helper? -%>
17
+ require_relative 'wait_helper'
18
+ <%- end -%>
19
+ <%- if web? -%>
20
+ require_relative 'session_state'
21
+ <%- end -%>
22
+ <%- if web? && examples? -%>
23
+ require_relative '../models/model_factory'
24
+ require_relative '../page_objects/pages/login'
25
+ require_relative '../page_objects/pages/account'
26
+ <%- end -%>
16
27
 
17
28
  module SpecHelper
18
29
  RSpec.configure do |config|
@@ -26,6 +37,9 @@ module SpecHelper
26
37
  <%- else -%>
27
38
  config.include(DriverHelper)
28
39
  <%- end -%>
40
+ <%- if wait_helper? -%>
41
+ config.include(WaitHelper)
42
+ <%- end -%>
29
43
  <%- if mobile? -%>
30
44
  config.before(:each) do
31
45
  driver.start_driver
@@ -49,6 +63,20 @@ module SpecHelper
49
63
  @video_recorder = VideoHelper.recorder_for(<%= watir? ? 'browser' : 'driver' %>)
50
64
  @video_recorder.start
51
65
  end
66
+ <%- if web? && examples? -%>
67
+
68
+ config.before(:each, :logged_in) do
69
+ user = ModelFactory.for('users')['registered user']
70
+ account = Account.new(<%= watir? ? 'browser' : 'driver' %>)
71
+ SessionState.new(<%= watir? ? 'browser.driver' : 'driver' %>).reuse(
72
+ valid: -> { account.tap(&:visit).header.customer_name == "Welcome back #{user['name']}" }
73
+ ) do
74
+ login = Login.new(<%= watir? ? 'browser' : 'driver' %>)
75
+ login.visit
76
+ login.login(user['username'], user['password'])
77
+ end
78
+ end
79
+ <%- end -%>
52
80
 
53
81
  config.after(:each) do |example|
54
82
  example_name = example.description