ruby_raider 3.13.0 → 4.0.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 (225) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/runtime.yml +38 -0
  3. data/.github/workflows/system_tests.yml +1 -23
  4. data/.reek.yml +1 -0
  5. data/.rspec +1 -0
  6. data/Gemfile +2 -0
  7. data/README.md +16 -49
  8. data/RELEASE.md +22 -0
  9. data/bin/raider +5 -0
  10. data/lib/commands/scaffolding_commands.rb +2 -25
  11. data/lib/commands/utility_commands.rb +23 -3
  12. data/lib/generators/automation/automation_generator.rb +0 -5
  13. data/lib/generators/automation/templates/component.tt +1 -0
  14. data/lib/generators/automation/templates/home.tt +2 -2
  15. data/lib/generators/automation/templates/login.tt +6 -26
  16. data/lib/generators/automation/templates/page.tt +23 -18
  17. data/lib/generators/automation/templates/partials/home_page_selector.tt +1 -2
  18. data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -8
  19. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +1 -2
  20. data/lib/generators/automation/templates/partials/url_methods.tt +8 -2
  21. data/lib/generators/automation/templates/partials/visit_method.tt +0 -5
  22. data/lib/generators/automation/templates/pdp.tt +1 -9
  23. data/lib/generators/common_generator.rb +0 -2
  24. data/lib/generators/generator.rb +12 -19
  25. data/lib/generators/helper_generator.rb +21 -19
  26. data/lib/generators/infrastructure/templates/azure_pipelines.tt +1 -1
  27. data/lib/generators/infrastructure/templates/github.tt +2 -3
  28. data/lib/generators/infrastructure/templates/github_appium.tt +0 -8
  29. data/lib/generators/invoke_generators.rb +4 -23
  30. data/lib/generators/menu_generator.rb +1 -9
  31. data/lib/generators/rspec/templates/accessibility_spec.tt +1 -9
  32. data/lib/generators/rspec/templates/spec.tt +8 -24
  33. data/lib/generators/template_renderer/partial_cache.rb +3 -1
  34. data/lib/generators/template_renderer.rb +1 -1
  35. data/lib/generators/templates/common/gemfile.tt +3 -10
  36. data/lib/generators/templates/common/git_ignore.tt +0 -5
  37. data/lib/generators/templates/common/partials/mobile_config.tt +3 -1
  38. data/lib/generators/templates/common/partials/web_config.tt +10 -1
  39. data/lib/generators/templates/common/rakefile.tt +0 -15
  40. data/lib/generators/templates/common/read_me.tt +357 -250
  41. data/lib/generators/templates/common/rubocop.tt +0 -2
  42. data/lib/generators/templates/helpers/driver_helper.tt +8 -0
  43. data/lib/generators/templates/helpers/gesture_helper.tt +78 -0
  44. data/lib/generators/templates/helpers/network_helper.tt +521 -0
  45. data/lib/generators/templates/helpers/partials/quit_driver.tt +0 -2
  46. data/lib/generators/templates/helpers/partials/screenshot.tt +0 -2
  47. data/lib/generators/templates/helpers/partials/selenium_driver.tt +5 -2
  48. data/lib/generators/templates/helpers/screenshot_helper.tt +490 -0
  49. data/lib/generators/templates/helpers/spec_helper.tt +39 -29
  50. data/lib/generators/templates/helpers/trace_helper.tt +240 -95
  51. data/lib/generators/templates/helpers/video_helper.tt +3 -8
  52. data/lib/ruby_raider/runtime/actionability.rb +129 -0
  53. data/lib/ruby_raider/runtime/actionable.rb +49 -0
  54. data/lib/ruby_raider/runtime/by_role.rb +55 -0
  55. data/lib/ruby_raider/runtime/config.rb +79 -0
  56. data/lib/ruby_raider/runtime/context.rb +38 -0
  57. data/lib/ruby_raider/runtime/errors.rb +69 -0
  58. data/lib/ruby_raider/runtime/finders.rb +73 -0
  59. data/lib/ruby_raider/runtime/js/actionability.js +134 -0
  60. data/lib/ruby_raider/runtime/js/role_engine.js +315 -0
  61. data/lib/ruby_raider/runtime/locator_converter.rb +72 -0
  62. data/lib/ruby_raider/runtime/matchers/base.rb +222 -0
  63. data/lib/ruby_raider/runtime/matchers/element.rb +183 -0
  64. data/lib/ruby_raider/runtime/matchers/page.rb +71 -0
  65. data/lib/ruby_raider/runtime/matchers/presence.rb +41 -0
  66. data/lib/ruby_raider/runtime/matchers/retrying_block.rb +104 -0
  67. data/lib/ruby_raider/runtime/observer.rb +170 -0
  68. data/lib/ruby_raider/runtime/role_finder.rb +83 -0
  69. data/lib/ruby_raider/runtime/rspec.rb +78 -0
  70. data/lib/ruby_raider/runtime/selectors.rb +60 -0
  71. data/lib/ruby_raider/runtime/strict.rb +142 -0
  72. data/lib/ruby_raider/runtime/wait.rb +84 -0
  73. data/lib/ruby_raider/runtime.rb +20 -0
  74. data/lib/scaffolding/name_normalizer.rb +1 -1
  75. data/lib/scaffolding/project_detector.rb +2 -28
  76. data/lib/scaffolding/scaffolding.rb +0 -20
  77. data/lib/scaffolding/templates/component.tt +0 -14
  78. data/lib/scaffolding/templates/page_object.tt +2 -14
  79. data/lib/scaffolding/templates/spec.tt +1 -9
  80. data/lib/utilities/utilities.rb +10 -8
  81. data/lib/version +1 -1
  82. data/ruby_raider.gemspec +2 -1
  83. data/sig/commands/scaffolding_commands.rbs +0 -2
  84. data/sig/commands/utility_commands.rbs +6 -0
  85. data/sig/generators/generator.rbs +0 -4
  86. data/sig/generators/helper_generator.rbs +0 -1
  87. data/sig/generators/menu_generator.rbs +0 -1
  88. data/sig/ruby_raider/runtime/actionability.rbs +25 -0
  89. data/sig/ruby_raider/runtime/actionable.rbs +13 -0
  90. data/sig/ruby_raider/runtime/by_role.rbs +15 -0
  91. data/sig/ruby_raider/runtime/config.rbs +26 -0
  92. data/sig/ruby_raider/runtime/context.rbs +12 -0
  93. data/sig/ruby_raider/runtime/errors.rbs +36 -0
  94. data/sig/ruby_raider/runtime/finders.rbs +26 -0
  95. data/sig/ruby_raider/runtime/locator_converter.rbs +21 -0
  96. data/sig/ruby_raider/runtime/matchers/base.rbs +64 -0
  97. data/sig/ruby_raider/runtime/matchers/element.rbs +78 -0
  98. data/sig/ruby_raider/runtime/matchers/page.rbs +38 -0
  99. data/sig/ruby_raider/runtime/matchers/presence.rbs +21 -0
  100. data/sig/ruby_raider/runtime/matchers/retrying_block.rbs +27 -0
  101. data/sig/ruby_raider/runtime/observer.rbs +46 -0
  102. data/sig/ruby_raider/runtime/role_finder.rbs +18 -0
  103. data/sig/ruby_raider/runtime/selectors.rbs +21 -0
  104. data/sig/ruby_raider/runtime/strict.rbs +35 -0
  105. data/sig/ruby_raider/runtime/wait.rbs +17 -0
  106. data/sig/ruby_raider/runtime.rbs +2 -0
  107. data/sig/scaffolding/project_detector.rbs +0 -4
  108. data/sig/scaffolding/scaffolding.rbs +0 -5
  109. data/sig/utilities/utilities.rbs +2 -1
  110. data/sig/vendor/selenium_webdriver.rbs +71 -0
  111. data/spec/commands/raider_commands_spec.rb +38 -3
  112. data/spec/commands/scaffolding_commands_spec.rb +27 -0
  113. data/spec/commands/utility_commands_spec.rb +53 -0
  114. data/spec/generators/gesture_helper_spec.rb +123 -0
  115. data/spec/generators/network_helper_spec.rb +634 -0
  116. data/spec/generators/screenshot_helper_spec.rb +776 -0
  117. data/spec/generators/template_renderer_spec.rb +0 -4
  118. data/spec/generators/trace_helper_spec.rb +489 -97
  119. data/spec/generators/video_helper_spec.rb +0 -9
  120. data/spec/integration/commands/browser_update_after_creation_spec.rb +0 -8
  121. data/spec/integration/commands/scaffolding_commands_spec.rb +12 -70
  122. data/spec/integration/commands/utility_commands_spec.rb +19 -41
  123. data/spec/integration/content/azure_content_spec.rb +2 -6
  124. data/spec/integration/content/ci_content_spec.rb +1 -55
  125. data/spec/integration/content/common_content_spec.rb +1 -118
  126. data/spec/integration/content/config_content_spec.rb +34 -31
  127. data/spec/integration/content/gemfile_content_spec.rb +7 -72
  128. data/spec/integration/content/helper_content_spec.rb +105 -99
  129. data/spec/integration/content/page_content_spec.rb +85 -87
  130. data/spec/integration/content/readme_content_spec.rb +134 -75
  131. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  132. data/spec/integration/content/rubocop_content_spec.rb +1 -1
  133. data/spec/integration/content/syntax_validation_spec.rb +2 -2
  134. data/spec/integration/content/test_content_spec.rb +23 -147
  135. data/spec/integration/end_to_end_features_spec.rb +1 -43
  136. data/spec/integration/end_to_end_spec.rb +319 -326
  137. data/spec/integration/generators/automation_generator_spec.rb +3 -37
  138. data/spec/integration/generators/axe_addon_spec.rb +2 -35
  139. data/spec/integration/generators/azure_generator_spec.rb +1 -1
  140. data/spec/integration/generators/common_generator_spec.rb +1 -40
  141. data/spec/integration/generators/config_features_spec.rb +1 -24
  142. data/spec/integration/generators/github_generator_spec.rb +0 -12
  143. data/spec/integration/generators/helpers_generator_spec.rb +74 -80
  144. data/spec/integration/generators/invoke_generators_spec.rb +54 -45
  145. data/spec/integration/generators/rspec_generator_spec.rb +0 -6
  146. data/spec/integration/scaffolding_e2e_spec.rb +17 -179
  147. data/spec/integration/spec_helper.rb +4 -6
  148. data/spec/integration/trace_format_checker.rb +11 -2
  149. data/spec/integration/trace_page_server.rb +72 -5
  150. data/spec/menus/menu_generator_spec.rb +14 -39
  151. data/spec/runtime/actionability_spec.rb +207 -0
  152. data/spec/runtime/actionable_spec.rb +125 -0
  153. data/spec/runtime/browser/actionability_script_browser_spec.rb +150 -0
  154. data/spec/runtime/browser/actionable_browser_spec.rb +98 -0
  155. data/spec/runtime/browser/finders_browser_spec.rb +148 -0
  156. data/spec/runtime/browser/matchers_browser_spec.rb +67 -0
  157. data/spec/runtime/browser/retrying_block_browser_spec.rb +37 -0
  158. data/spec/runtime/browser/role_engine_browser_spec.rb +184 -0
  159. data/spec/runtime/browser/strict_browser_spec.rb +38 -0
  160. data/spec/runtime/by_role_spec.rb +52 -0
  161. data/spec/runtime/config_spec.rb +79 -0
  162. data/spec/runtime/context_spec.rb +73 -0
  163. data/spec/runtime/errors_spec.rb +89 -0
  164. data/spec/runtime/finders_spec.rb +165 -0
  165. data/spec/runtime/fixtures/actionability.html +70 -0
  166. data/spec/runtime/fixtures/locators.html +46 -0
  167. data/spec/runtime/fixtures/matchers.html +33 -0
  168. data/spec/runtime/fixtures/roles.html +162 -0
  169. data/spec/runtime/matchers/base_spec.rb +165 -0
  170. data/spec/runtime/matchers/element_spec.rb +277 -0
  171. data/spec/runtime/matchers/page_spec.rb +102 -0
  172. data/spec/runtime/matchers/presence_spec.rb +59 -0
  173. data/spec/runtime/matchers/retrying_block_spec.rb +165 -0
  174. data/spec/runtime/observer_spec.rb +341 -0
  175. data/spec/runtime/role_finder_spec.rb +156 -0
  176. data/spec/runtime/role_query_spec.rb +75 -0
  177. data/spec/runtime/selectors_spec.rb +135 -0
  178. data/spec/runtime/strict_spec.rb +257 -0
  179. data/spec/runtime/support/browser.rb +48 -0
  180. data/spec/runtime/support/fake_bridge.rb +117 -0
  181. data/spec/runtime/wait_spec.rb +71 -0
  182. data/spec/scaffolding/name_normalizer_spec.rb +3 -2
  183. data/spec/scaffolding/scaffold_project_detector_spec.rb +4 -43
  184. data/spec/system/selenium_spec.rb +1 -5
  185. data/spec/system/support/system_test_helper.rb +2 -5
  186. data/spec/utilities/headless_config_spec.rb +0 -7
  187. data/spec/utilities/utilities_spec.rb +24 -0
  188. metadata +113 -48
  189. data/lib/generators/automation/templates/header.tt +0 -9
  190. data/lib/generators/cucumber/cucumber_generator.rb +0 -58
  191. data/lib/generators/cucumber/templates/accessibility_feature.tt +0 -5
  192. data/lib/generators/cucumber/templates/accessibility_steps.tt +0 -17
  193. data/lib/generators/cucumber/templates/cucumber.tt +0 -1
  194. data/lib/generators/cucumber/templates/env.tt +0 -7
  195. data/lib/generators/cucumber/templates/feature.tt +0 -21
  196. data/lib/generators/cucumber/templates/partials/appium_env.tt +0 -26
  197. data/lib/generators/cucumber/templates/partials/driver_world.tt +0 -18
  198. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +0 -20
  199. data/lib/generators/cucumber/templates/partials/selenium_env.tt +0 -120
  200. data/lib/generators/cucumber/templates/partials/watir_env.tt +0 -48
  201. data/lib/generators/cucumber/templates/partials/watir_world.tt +0 -4
  202. data/lib/generators/cucumber/templates/partials/web_steps.tt +0 -28
  203. data/lib/generators/cucumber/templates/steps.tt +0 -5
  204. data/lib/generators/cucumber/templates/user_factory.tt +0 -15
  205. data/lib/generators/cucumber/templates/world.tt +0 -7
  206. data/lib/generators/templates/helpers/browser_helper.tt +0 -31
  207. data/lib/generators/templates/helpers/raider_element.tt +0 -159
  208. data/lib/generators/templates/helpers/wait_helper.tt +0 -611
  209. data/lib/scaffolding/templates/feature.tt +0 -6
  210. data/lib/scaffolding/templates/steps.tt +0 -19
  211. data/sig/generators/cucumber/cucumber_generator.rbs +0 -12
  212. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +0 -175
  213. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +0 -24
  214. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +0 -37
  215. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +0 -33
  216. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +0 -174
  217. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +0 -37
  218. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +0 -33
  219. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +0 -49
  220. data/spec/integration/content/cucumber_reporter_content_spec.rb +0 -161
  221. data/spec/integration/generators/cucumber_generator_spec.rb +0 -100
  222. data/spec/integration/helpers/wait_helper_mobile_spec.rb +0 -281
  223. data/spec/integration/helpers/wait_helper_spec.rb +0 -679
  224. data/spec/integration/helpers/wait_helper_support.rb +0 -183
  225. data/spec/system/watir_spec.rb +0 -38
@@ -7,6 +7,7 @@ require 'tmpdir'
7
7
  require 'yaml'
8
8
  require 'zip'
9
9
  require_relative '../../lib/generators/helper_generator'
10
+ require_relative '../../lib/ruby_raider/runtime'
10
11
  require_relative '../integration/trace_format_checker'
11
12
 
12
13
  $LOAD_PATH.unshift(File.expand_path('fixtures/video_helper_stubs', __dir__))
@@ -54,7 +55,7 @@ class FakeTraceBidi
54
55
  end
55
56
 
56
57
  # Renders the real trace_helper.tt for a Selenium project, loads it, and drives its
57
- # Recorder the way WaitHelper's observer hook and the navigation listener do, with a
58
+ # Recorder the way the runtime reports to Raider.observer and the navigation listener does, with a
58
59
  # driver double. The zips it writes are read back with rubyzip.
59
60
  # rubocop:disable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations, RSpec/ExampleLength
60
61
  describe 'Generated TraceHelper' do
@@ -67,22 +68,13 @@ describe 'Generated TraceHelper' do
67
68
  let(:bidi) { FakeTraceBidi.new(jpeg) }
68
69
  let(:html) { "<html><head><title>Login</title></head><body>#{'<p>row</p>' * 50}</body></html>" }
69
70
  let(:capabilities) { double('capabilities', browser_name: 'chrome', browser_version: '131.0.6778.85') }
70
- let(:window) { double('window', size: double('size', width: 1920, height: 1080)) }
71
71
  let(:driver) do
72
72
  double('driver', screenshot_as: png, page_source: html, current_url: 'https://example.test/login', title: 'Login',
73
- capabilities: capabilities, manage: double('manage', window: window), execute_script: 2,
73
+ capabilities: capabilities, execute_script: 2,
74
74
  bidi: bidi, window_handle: 'context-1')
75
75
  end
76
- let(:wait_helper) do
77
- Module.new do
78
- class << self
79
- attr_accessor :observer
80
- end
81
- end
82
- end
83
76
  let(:stderr) { StringIO.new }
84
77
  let(:warnings) { stderr.string.lines.grep(/\[trace\]/) }
85
- let(:source) { { file: 'spec/login_page_spec.rb', line: 12 } }
86
78
  let(:failed_test) { { identifier: 'spec/login_page_spec.rb[1:1]', full_name: 'Login page logs in', status: 'failed', attempt: 2, error: nil } }
87
79
 
88
80
  def render_and_load
@@ -93,16 +85,25 @@ describe 'Generated TraceHelper' do
93
85
  load path
94
86
  end
95
87
 
88
+ # The inner viewport, which is what rects and the viewport screenshot are measured in.
89
+ def viewport_script(size)
90
+ allow(driver).to receive(:execute_script).with(/innerWidth/).and_return(size)
91
+ end
92
+
96
93
  def started_recorder(**lambdas)
97
94
  TraceHelper.recorder_for(driver, **lambdas).tap(&:start)
98
95
  end
99
96
 
100
- # What WaitHelper reports for one action. With an element, it was found ready (action_ready).
101
- def act(recorder, action, selector, status: :passed, error: nil, within: [], args: nil, element: nil)
102
- token = recorder.action_started(action: action, locator: { strategy: 'id', selector: selector }, within: within,
103
- source: source, args: args)
104
- recorder.action_ready(token, element: element) if element
105
- recorder.action_finished(token, status: status, waited_ms: 95, retries: 1, error: error, element: element)
97
+ # What the runtime reports to Raider.observer for one action, through Raider.report. With an element, it was found
98
+ # ready (action_ready). Returns the recorder's token.
99
+ def act(_recorder, action, selector, status: :passed, error: nil, within: [], args: nil, element: nil)
100
+ started = { action: action, locator: { strategy: 'id', selector: selector }, within: within, args: args }
101
+ token = Raider.report(:action_started) { started } # ACT_LINE
102
+ Raider.report(:action_ready, token) { { element: element } } if element
103
+ Raider.report(:action_finished, token) do
104
+ { status: status, waited_ms: 95, retries: 1, error: error, element: element }
105
+ end
106
+ token
106
107
  end
107
108
 
108
109
  # What Page.capture's script answers: the inlined CSS, the sheets it could not read and the title.
@@ -117,6 +118,11 @@ describe 'Generated TraceHelper' do
117
118
  allow(driver).to receive(:execute_script).with(/getBoundingClientRect/, element).and_return(info)
118
119
  end
119
120
 
121
+ # The line of act that reports a start, which is where each recorded source points.
122
+ def act_line
123
+ File.foreach(__FILE__).find_index { |line| line.end_with?("# ACT_LINE\n") && line.include?('Raider.report') } + 1
124
+ end
125
+
120
126
  def snapshot_of(path, index)
121
127
  Zip::File.open(path) { |zip| zip.read(format('steps/%04d.html', index)) }
122
128
  end
@@ -135,6 +141,7 @@ describe 'Generated TraceHelper' do
135
141
  Dir.chdir(project_dir) { example.run }
136
142
  ensure
137
143
  $stderr = original_stderr
144
+ Raider.observer = nil
138
145
  Object.send(:remove_const, :TraceHelper) if defined?(TraceHelper)
139
146
  FileUtils.rm_rf(project_dir)
140
147
  end
@@ -143,11 +150,11 @@ describe 'Generated TraceHelper' do
143
150
  # Recorder tmpdirs land inside the project, which the around hook removes.
144
151
  stub_const('ENV', ENV.to_h.merge(env).merge('TMPDIR' => File.join(project_dir, 'tmp')).compact)
145
152
  FileUtils.mkdir_p(File.join(project_dir, 'tmp'))
146
- stub_const('WaitHelper', wait_helper)
147
153
  render_and_load
148
154
  end
149
155
 
150
156
  it 'writes a zip whose trace.json has format raider-trace, version 1 and the spec §1 top-level keys' do
157
+ viewport_script([1905, 969])
151
158
  recorder = started_recorder
152
159
  recorder.navigated('https://example.test/login')
153
160
  trace = kept(recorder)
@@ -159,12 +166,49 @@ describe 'Generated TraceHelper' do
159
166
  'test' => { 'identifier' => 'spec/login_page_spec.rb[1:1]', 'full_name' => 'Login page logs in',
160
167
  'status' => 'failed', 'attempt' => 2, 'error' => nil },
161
168
  'browser' => { 'name' => 'chrome', 'version' => '131.0.6778.85' },
162
- 'viewport' => { 'width' => 1920, 'height' => 1080 }, 'device_pixel_ratio' => 2,
169
+ 'viewport' => { 'width' => 1905, 'height' => 969 }, 'device_pixel_ratio' => 2,
163
170
  'capabilities' => { 'network' => true, 'console' => true, 'screenshots' => true, 'snapshots' => true },
164
171
  'truncated' => { 'steps' => 0, 'screenshots' => 0, 'snapshots' => 0, 'network' => 0, 'console' => 0 })
165
172
  expect(trace['finished_at'] >= trace['started_at']).to be(true)
166
173
  end
167
174
 
175
+ it 'records a match_screenshot step with the given PNG as its screenshot, without taking another one' do
176
+ taken = 0
177
+ recorder = started_recorder(screenshot: ->(_driver) { [(taken += 1) && 'NAV', 'png'] })
178
+ recorder.navigated('https://example.test/login')
179
+ recorder.screenshot_compared(name: 'home', status: :failed, png: 'PNG-BYTES')
180
+ path = recorder.stop(keep: true, test: failed_test)
181
+ steps = TraceFormatChecker.read(path).first['steps']
182
+
183
+ expect(TraceFormatChecker.problems(path)).to eq([])
184
+ expect(steps.last).to include('kind' => 'action', 'action' => 'match_screenshot', 'name' => 'home', 'locator' => nil,
185
+ 'status' => 'failed', 'error' => nil, 'screenshot' => 'steps/0002.png', 'snapshot' => nil)
186
+ expect(Zip::File.open(path) { |zip| zip.read('steps/0002.png') }).to eq('PNG-BYTES')
187
+ expect(taken).to eq(1)
188
+ end
189
+
190
+ it 'gives the match_screenshot step the comparison error and appends no failure step for it' do
191
+ recorder = started_recorder
192
+ error = RuntimeError.new('1 pixel differs')
193
+ recorder.screenshot_compared(name: 'home', status: :failed, png: 'PNG', error: error)
194
+ steps = kept(recorder, failed_test.merge(error: error))['steps']
195
+
196
+ expect(steps.map { |step| step.values_at('kind', 'action', 'status') }).to eq([%w[action match_screenshot failed]])
197
+ expect(steps.first['error']).to eq('class' => 'RuntimeError', 'message' => '1 pixel differs')
198
+ end
199
+
200
+ it 'records a passed comparison and never fails the test when the step cannot be written' do
201
+ recorder = started_recorder
202
+ recorder.screenshot_compared(name: 'home', status: :passed, png: 'PNG')
203
+ expect(kept(recorder)['steps'].last).to include('action' => 'match_screenshot', 'status' => 'passed')
204
+ allow(recorder).to receive(:add).and_raise('boom')
205
+ expect { recorder.screenshot_compared(name: 'again', status: :passed, png: 'PNG') }.not_to raise_error
206
+ end
207
+
208
+ it 'does not take match_screenshot as a media action' do
209
+ expect(TraceHelper::MEDIA_ACTIONS).not_to include('match_screenshot')
210
+ end
211
+
168
212
  it 'records navigate and click steps in order with index, kind, action, locator, source, waited_ms, retries' do
169
213
  recorder = started_recorder
170
214
  recorder.navigated('https://example.test/login')
@@ -178,7 +222,7 @@ describe 'Generated TraceHelper' do
178
222
  # No locator.label: a page object's element method has returned before the action runs, so it is never known.
179
223
  expect(steps.last).to include('locator' => { 'strategy' => 'id', 'selector' => 'go',
180
224
  'within' => [{ 'strategy' => 'css', 'selector' => '.form' }] },
181
- 'source' => { 'file' => 'spec/login_page_spec.rb', 'line' => 12 },
225
+ 'source' => { 'file' => __FILE__, 'line' => act_line },
182
226
  'waited_ms' => 95, 'retries' => 1, 'status' => 'passed',
183
227
  'screenshot' => 'steps/0002.jpg', 'snapshot' => 'steps/0002.html')
184
228
  expect(steps.last['duration_ms']).to be_a(Integer)
@@ -206,47 +250,6 @@ describe 'Generated TraceHelper' do
206
250
  expect(names).to contain_exactly('trace.json', 'steps/0001.jpg', 'steps/0001.html')
207
251
  end
208
252
 
209
- it 'records a Cucumber step as a cucumber_step group carrying its name, with the actions it ran as children' do
210
- recorder = started_recorder
211
- group = recorder.step_started(name: 'When I log in as admin', source: source)
212
- act(recorder, :click, 'login')
213
- recorder.step_finished(group, status: :passed, error: nil)
214
- act(recorder, :click, 'outside')
215
- steps = kept(recorder)['steps']
216
-
217
- expect(steps.map { |step| step.values_at('index', 'parent_index', 'kind', 'action', 'status', 'name') })
218
- .to eq([[1, nil, 'group', 'cucumber_step', 'passed', 'When I log in as admin'], [2, 1, 'action', 'click', 'passed', nil],
219
- [3, nil, 'action', 'click', 'passed', nil]])
220
- expect(steps[1..].map { |step| step.key?('name') }).to eq([false, false])
221
- end
222
-
223
- it 'records a failed Cucumber step with its error' do
224
- recorder = started_recorder
225
- group = recorder.step_started(name: 'Then I see the dashboard', source: source)
226
- recorder.step_finished(group, status: :failed, error: RuntimeError.new('no dashboard'))
227
- step = kept(recorder)['steps'].first
228
-
229
- expect(step).to include('status' => 'failed', 'error' => { 'class' => 'RuntimeError', 'message' => 'no dashboard' })
230
- end
231
-
232
- it 'keeps only the last pass of eventually as child steps and counts the earlier passes in retries' do
233
- recorder = started_recorder
234
- group = recorder.group_started(action: :eventually, source: source)
235
- 2.times do |pass|
236
- act(recorder, :click, 'refresh')
237
- act(recorder, :text, 'welcome', status: :failed, error: RuntimeError.new("not yet #{pass}"))
238
- recorder.group_retried(group, error: RuntimeError.new("not yet #{pass}"))
239
- end
240
- act(recorder, :click, 'refresh')
241
- act(recorder, :text, 'welcome')
242
- recorder.group_finished(group, status: :passed, retries: 2, error: nil)
243
- steps = kept(recorder)['steps']
244
-
245
- expect(steps.map { |step| step.values_at('index', 'parent_index', 'kind', 'action', 'status') })
246
- .to eq([[1, nil, 'group', 'eventually', 'passed'], [2, 1, 'action', 'click', 'passed'], [3, 1, 'action', 'text', 'passed']])
247
- expect(steps.first).to include('retries' => 2, 'error' => { 'class' => 'RuntimeError', 'message' => 'not yet 1' }, 'screenshot' => nil, 'snapshot' => nil)
248
- end
249
-
250
253
  it 'appends a failure step with the error when no traced step was running' do
251
254
  recorder = started_recorder
252
255
  recorder.navigated('https://example.test/login')
@@ -278,20 +281,6 @@ describe 'Generated TraceHelper' do
278
281
  .to eq([['action', 'failed', { 'class' => 'RuntimeError', 'message' => 'Waited 10s for {id: "go"} to be visible' }]])
279
282
  end
280
283
 
281
- it 'still gives the test error a step when the step that raised it was dropped by eventually' do
282
- recorder = started_recorder
283
- error = RuntimeError.new('not yet')
284
- group = recorder.group_started(action: :eventually, source: source)
285
- act(recorder, :click, 'refresh', status: :failed, error: error)
286
- recorder.group_retried(group, error: error)
287
- act(recorder, :click, 'refresh')
288
- recorder.group_finished(group, status: :passed, retries: 1, error: nil)
289
- steps = kept(recorder, failed_test.merge(error: error))['steps']
290
-
291
- expect(steps.map { |step| step.values_at('kind', 'status') }).to eq([%w[group passed], %w[action passed]])
292
- expect(steps.first['error']).to eq('class' => 'RuntimeError', 'message' => 'not yet')
293
- end
294
-
295
284
  it 'appends a failure step when the step that raised the test error was dropped by max_steps' do
296
285
  allow(TraceHelper).to receive(:config).and_return(trace_config.merge('max_steps' => 2))
297
286
  recorder = started_recorder
@@ -317,6 +306,20 @@ describe 'Generated TraceHelper' do
317
306
  expect(TraceHelper.enabled?).to be(true)
318
307
  end
319
308
 
309
+ describe 'viewport' do
310
+ it 'is nil when the inner size cannot be read' do
311
+ viewport_script(nil)
312
+
313
+ expect(kept(started_recorder)['viewport']).to eq('width' => nil, 'height' => nil)
314
+ end
315
+
316
+ it 'is nil when the script raises' do
317
+ allow(driver).to receive(:execute_script).with(/innerWidth/).and_raise(Selenium::WebDriver::Error::WebDriverError, 'no page')
318
+
319
+ expect(kept(started_recorder)['viewport']).to eq('width' => nil, 'height' => nil)
320
+ end
321
+ end
322
+
320
323
  describe 'snapshots' do
321
324
  def sanitize(page, css: '', skipped: 0)
322
325
  TraceHelper::Snapshot.sanitize(page, css: css, skipped: skipped)
@@ -458,11 +461,10 @@ describe 'Generated TraceHelper' do
458
461
  element_script(field, secret: false)
459
462
  recorder = started_recorder
460
463
  act(recorder, :send_keys, 'user', args: ['admin', :enter], element: field)
461
- act(recorder, :select_by, 'colour', args: [:text, 'Blue'], element: field)
462
464
  act(recorder, :click, 'go', element: field)
463
465
  steps = kept(recorder)['steps']
464
466
 
465
- expect(steps.map { |step| step.values_at('value', 'secret') }).to eq([['admin{enter}', false], ['Blue', false], [nil, nil]])
467
+ expect(steps.map { |step| step.values_at('value', 'secret') }).to eq([['admin{enter}', false], [nil, nil]])
466
468
  expect(steps.last.keys).not_to include('value', 'secret')
467
469
  end
468
470
 
@@ -578,17 +580,25 @@ describe 'Generated TraceHelper' do
578
580
  expect(Dir.children(File.join(project_dir, 'tmp'))).to be_empty
579
581
  end
580
582
 
581
- it 'makes the recorder the WaitHelper observer until cleanup, so no test inherits it' do
583
+ it 'makes the recorder the Raider observer until cleanup, so no test inherits it' do
582
584
  recorder = started_recorder
583
- expect(WaitHelper.observer).to be(recorder)
585
+ expect(Raider.observer).to be(recorder)
584
586
 
585
587
  recorder.stop(keep: false, test: failed_test)
586
588
  recorder.cleanup
587
- expect(WaitHelper.observer).to be_nil
589
+ expect(Raider.observer).to be_nil
588
590
  end
589
591
 
590
- it 'records navigation and the failure without WaitHelper' do
591
- hide_const('WaitHelper')
592
+ it 'leaves an observer that is not the recorder alone on release' do
593
+ other = Object.new
594
+ recorder = started_recorder
595
+ Raider.observer = other
596
+ TraceHelper.release(recorder)
597
+
598
+ expect(Raider.observer).to be(other)
599
+ end
600
+
601
+ it 'records navigation and the failure with no observer events' do
592
602
  recorder = started_recorder
593
603
  recorder.navigated('https://example.test/login')
594
604
  steps = kept(recorder, failed_test.merge(error: RuntimeError.new('boom')))['steps']
@@ -596,25 +606,50 @@ describe 'Generated TraceHelper' do
596
606
  expect(steps.map { |step| step['kind'] }).to eq(%w[navigate failure])
597
607
  end
598
608
 
609
+ it 'takes each step source from the first frame outside the runtime and the helpers' do
610
+ recorder = started_recorder
611
+ act(recorder, :click, 'go')
612
+ act(recorder, :send_keys, 'user', args: ['admin'])
613
+ sources = kept(recorder)['steps'].map { |step| step['source'] }
614
+
615
+ expect(sources).to all(include('file' => __FILE__))
616
+ expect(sources.first['line']).to be_a(Integer)
617
+ end
618
+
619
+ it 'gives the token nil, so the runtime sends no ready or finished, when not recording' do
620
+ recorder = TraceHelper.recorder_for(driver)
621
+
622
+ expect(recorder.action_started(action: :click, locator: { strategy: 'id', selector: 'go' }, within: [])).to be_nil
623
+ end
624
+
625
+ it 'records the actions the runtime emits as steps with their locator strategy' do
626
+ recorder = started_recorder
627
+ %w[click send_keys clear submit].each { |action| act(recorder, action, 'go', args: nil) }
628
+ steps = kept(recorder)['steps']
629
+
630
+ expect(steps.map { |step| step['action'] }).to eq(%w[click send_keys clear submit])
631
+ expect(TraceHelper::MEDIA_ACTIONS).to eq(%w[navigate click send_keys clear submit])
632
+ end
633
+
599
634
  context 'with TRACE=off' do
600
635
  let(:env) { { 'TRACE' => 'off' } }
601
636
 
602
- it 'returns a NullRecorder and leaves WaitHelper.observer nil with TRACE=off or enabled: false' do
637
+ it 'returns a NullRecorder and leaves Raider.observer nil with TRACE=off or enabled: false' do
603
638
  recorder = TraceHelper.recorder_for(driver)
604
639
 
605
640
  expect(recorder).to be_a(TraceHelper::NullRecorder)
606
641
  expect(recorder.start).to be_nil
607
642
  expect(recorder.stop(keep: true, test: failed_test)).to be_nil
608
- expect(WaitHelper.observer).to be_nil
643
+ expect(Raider.observer).to be_nil
609
644
  end
610
645
  end
611
646
 
612
647
  context 'with enabled: false' do
613
648
  let(:trace_config) { { 'enabled' => false } }
614
649
 
615
- it 'returns a NullRecorder and leaves WaitHelper.observer nil with TRACE=off or enabled: false' do
650
+ it 'returns a NullRecorder and leaves Raider.observer nil with TRACE=off or enabled: false' do
616
651
  expect(TraceHelper.recorder_for(driver)).to be_a(TraceHelper::NullRecorder)
617
- expect(WaitHelper.observer).to be_nil
652
+ expect(Raider.observer).to be_nil
618
653
  end
619
654
  end
620
655
 
@@ -681,18 +716,214 @@ describe 'Generated TraceHelper' do
681
716
  expect(warnings).to eq(["[trace] could not write the trace: No space left on device; tracing disabled for this run\n"])
682
717
  expect(TraceHelper.enabled?).to be(false)
683
718
  expect(TraceHelper.recorder_for(driver)).to be_a(TraceHelper::NullRecorder)
684
- expect(WaitHelper.observer).to be_nil
719
+ expect(Raider.observer).to be_nil
720
+ end
721
+
722
+ describe 'a navigation in flight' do
723
+ let(:started_ms) { 1_793_568_600_020 }
724
+
725
+ def request_sent(id, url)
726
+ bidi.emit('network.beforeRequestSent', 'request' => { 'request' => id, 'method' => 'GET', 'url' => url },
727
+ 'timestamp' => started_ms)
728
+ end
729
+
730
+ it 'credits requests made between navigating and navigated to the navigate step' do
731
+ recorder = started_recorder
732
+ recorder.navigating('https://example.test/start')
733
+ request_sent('r1', 'https://example.test/start')
734
+ recorder.navigated('https://example.test/')
735
+ act(recorder, :click, 'go')
736
+ request_sent('r2', 'https://example.test/api')
737
+ trace = kept(recorder)
738
+
739
+ expect(trace['steps'].map { |step| step.values_at('index', 'action', 'status') }).to eq([[1, 'navigate', 'passed'], [2, 'click', 'passed']])
740
+ expect(trace['network'].map { |entry| entry.values_at('step_index', 'url') })
741
+ .to eq([[1, 'https://example.test/start'], [2, 'https://example.test/api']])
742
+ end
743
+
744
+ it 'keeps the old path: navigated alone adds one finished navigate step' do
745
+ recorder = started_recorder
746
+ recorder.navigated('https://example.test/login')
747
+
748
+ expect(kept(recorder)['steps'].map { |step| step.values_at('kind', 'status') }).to eq([%w[navigate passed]])
749
+ end
750
+
751
+ it 'finishes a navigation that never completed as failed at the next navigating' do
752
+ recorder = started_recorder
753
+ recorder.navigating('https://example.test/down')
754
+ recorder.navigating('https://example.test/up')
755
+ recorder.navigated('https://example.test/up')
756
+ steps = kept(recorder)['steps']
757
+
758
+ expect(steps.map { |step| step.values_at('kind', 'status', 'url') })
759
+ .to eq([['navigate', 'failed', 'https://example.test/down'], ['navigate', 'passed', 'https://example.test/login']])
760
+ end
761
+
762
+ it 'finishes a navigation that never completed as failed at stop' do
763
+ recorder = started_recorder
764
+ recorder.navigating('https://example.test/down')
765
+ steps = kept(recorder)['steps']
766
+
767
+ expect(steps.map { |step| step.values_at('kind', 'status') }).to eq([%w[navigate failed]])
768
+ end
769
+
770
+ it 'finishes a navigation that never completed as failed when the next action is added, with only its own duration' do
771
+ clock = 100.0
772
+ allow(TraceHelper).to receive(:monotonic_now) { clock }
773
+ recorder = started_recorder
774
+ recorder.navigating('https://example.test/down')
775
+ clock += 0.25
776
+ act(recorder, :click, 'go')
777
+ clock += 60
778
+ steps = kept(recorder)['steps']
779
+
780
+ expect(steps.map { |step| step.values_at('action', 'status') }).to eq([%w[navigate failed], %w[click passed]])
781
+ expect(steps.first['duration_ms']).to eq(250)
782
+ end
783
+
784
+ it 'adds no second failure step for the error a pending navigation ends the test with' do
785
+ error = Selenium::WebDriver::Error::WebDriverError.new('net::ERR_CONNECTION_REFUSED')
786
+ recorder = started_recorder
787
+ recorder.navigating('https://example.test/down')
788
+ steps = kept(recorder, failed_test.merge(error: error))['steps']
789
+
790
+ expect(steps.map { |step| step.values_at('kind', 'status') }).to eq([%w[navigate failed]])
791
+ expect(steps.first['error']).to include('message' => a_string_including('ERR_CONNECTION_REFUSED'))
792
+ end
793
+
794
+ it 'adds no failure step for the error of the action that followed a pending navigation' do
795
+ error = RuntimeError.new('boom')
796
+ recorder = started_recorder
797
+ recorder.navigating('https://example.test/down')
798
+ act(recorder, :click, 'go', status: :failed, error: error)
799
+ steps = kept(recorder, failed_test.merge(error: error))['steps']
800
+
801
+ expect(steps.map { |step| step.values_at('kind', 'status') }).to eq([%w[navigate failed], %w[action failed]])
802
+ end
803
+
804
+ it 'keeps the page url a back or forward navigation started from when the driver does not give one' do
805
+ alert = { open: false }
806
+ stub_const('VideoHelper', Module.new { define_singleton_method(:alert_open?) { |_driver| alert[:open] } })
807
+ recorder = started_recorder
808
+ recorder.navigated('https://example.test/login')
809
+ alert[:open] = true
810
+ recorder.navigating(nil)
811
+ recorder.navigated(nil)
812
+ steps = kept(recorder)['steps']
813
+
814
+ expect(steps.map { |step| step['url'] }).to eq(['https://example.test/login', 'https://example.test/login'])
815
+ end
816
+
817
+ it 'is driven by the listener before each navigation, and the null recorder ignores it' do
818
+ listener = TraceHelper.listener
819
+ recorder = started_recorder
820
+ listener.before_navigate_to('https://example.test/a', driver)
821
+ listener.after_navigate_to('https://example.test/a', driver)
822
+ listener.before_navigate_back(driver)
823
+ listener.after_navigate_back(driver)
824
+ listener.before_navigate_forward(driver)
825
+ listener.after_navigate_forward(driver)
826
+
827
+ expect(kept(recorder)['steps'].map { |step| step['status'] }).to eq(%w[passed passed passed])
828
+ expect(TraceHelper::NullRecorder.new.navigating('https://example.test/')).to be_nil
829
+ end
830
+ end
831
+
832
+ describe 'mocked requests' do
833
+ let(:started_ms) { 1_793_568_600_020 }
834
+ let(:recorder) { started_recorder }
835
+ let(:network) { kept(recorder)['network'] }
836
+
837
+ before { recorder.navigated('https://example.test/login') }
838
+
839
+ def start(id, hop = 0)
840
+ recorder.network_started([id, hop], { method: 'GET', url: "https://example.test/#{id}", status: nil, duration_ms: nil }, at: started_ms)
841
+ end
842
+
843
+ def complete(id, status, hop = 0)
844
+ recorder.network_completed([id, hop], { status: status }, started_ms + 7)
845
+ end
846
+
847
+ it 'marks a pending entry and still completes it' do
848
+ start('r1')
849
+ recorder.network_marked(['r1', 0], { mocked: 'fulfilled' })
850
+ complete('r1', 200)
851
+
852
+ expect(network.map { |entry| entry.values_at('mocked', 'status', 'duration_ms') }).to eq([['fulfilled', 200, 7]])
853
+ end
854
+
855
+ it 'applies an early mark when the entry starts' do
856
+ recorder.network_marked(['r1', 0], { mocked: 'failed' })
857
+ start('r1')
858
+
859
+ expect(network.map { |entry| entry['mocked'] }).to eq(['failed'])
860
+ expect(recorder.instance_variable_get(:@early_marks)).to be_empty
861
+ end
862
+
863
+ it 'marks only its hop' do
864
+ start('r1', 0)
865
+ start('r1', 1)
866
+ recorder.network_marked(['r1', 1], { mocked: 'fulfilled' })
867
+
868
+ expect(network.map { |entry| entry['mocked'] }).to eq([nil, 'fulfilled'])
869
+ end
870
+
871
+ it 'caps early marks at MAX_PENDING' do
872
+ cap = TraceHelper::Recorder::MAX_PENDING
873
+ (cap + 5).times { |index| recorder.network_marked(["m#{index}", 0], { mocked: 'fulfilled' }) }
874
+ marks = recorder.instance_variable_get(:@early_marks)
875
+
876
+ expect(marks.size).to eq(cap)
877
+ expect(marks.keys.first).to eq(['m5', 0])
878
+ end
879
+
880
+ it 'keeps the status from the mark when no completion arrives' do
881
+ start('r1')
882
+ recorder.network_marked(['r1', 0], { mocked: 'fulfilled', status: 503 })
883
+
884
+ expect(network.map { |entry| entry.values_at('mocked', 'status') }).to eq([['fulfilled', 503]])
885
+ end
886
+
887
+ it 'keeps the mark status when the completion has none' do
888
+ start('r1')
889
+ recorder.network_marked(['r1', 0], { mocked: 'fulfilled', status: 503 })
890
+ complete('r1', nil)
891
+
892
+ expect(network.map { |entry| entry['status'] }).to eq([503])
893
+ end
894
+
895
+ it 'is fed by NetworkHelper.observer, registered when recorder_for runs' do
896
+ helper = Module.new { class << self; attr_accessor :observer; end }
897
+ stub_const('NetworkHelper', helper)
898
+ started = started_recorder
899
+ started.network_started(['r1', 0], { url: 'https://example.test/r1' })
900
+ helper.observer.request_mocked(request_id: 'r1', redirect_count: 0, outcome: 'failed', status: nil)
901
+
902
+ expect(helper.observer).to be(TraceHelper::MockMarks)
903
+ expect(kept(started)['network'].map { |entry| entry['mocked'] }).to eq(['failed'])
904
+ end
905
+
906
+ it 'does not raise when a mark cannot be applied' do
907
+ allow(TraceHelper).to receive(:active_recorder).and_raise(RuntimeError, 'boom')
908
+
909
+ expect { TraceHelper::MockMarks.request_mocked(request_id: 'r1', redirect_count: 0, outcome: 'failed') }.not_to raise_error
910
+ end
911
+
912
+ it 'is accepted by the format checker, which rejects other values' do
913
+ trace = { 'network' => [{ 'mocked' => 'maybe' }, { 'mocked' => 'fulfilled' }, {}] }
914
+ expect(TraceFormatChecker.mocked_rules(trace['network']).size).to eq(1)
915
+ end
685
916
  end
686
917
 
687
918
  describe 'browser wiring' do
688
919
  let(:started_ms) { 1_793_568_600_020 }
689
920
  let(:bidi_events) { %w[network.beforeRequestSent network.responseCompleted network.fetchError log.entryAdded] }
690
921
 
691
- def request_sent(id, method, url, at)
692
- bidi.emit('network.beforeRequestSent',
693
- 'request' => { 'request' => id, 'method' => method, 'url' => url,
694
- 'headers' => [{ 'name' => 'Cookie', 'value' => { 'type' => 'string', 'value' => 'session=hush' } }] },
695
- 'timestamp' => at)
922
+ def request_sent(id, method, url, at, hop: 0)
923
+ bidi.emit('network.beforeRequestSent', 'redirectCount' => hop,
924
+ 'request' => { 'request' => id, 'method' => method, 'url' => url,
925
+ 'headers' => [{ 'name' => 'Cookie', 'value' => { 'type' => 'string', 'value' => 'session=hush' } }] },
926
+ 'timestamp' => at)
696
927
  end
697
928
 
698
929
  it 'subscribes passively to the four BiDi events and never adds a request handler' do
@@ -780,6 +1011,151 @@ describe 'Generated TraceHelper' do
780
1011
  )
781
1012
  end
782
1013
 
1014
+ # A redirect keeps the request id; each hop has its own redirectCount. Chrome starts hop 1 in the same
1015
+ # millisecond hop 0 completes, so the two events can arrive in either order.
1016
+ it 'keeps each hop of a redirect as its own entry with its own status and duration' do
1017
+ recorder = started_recorder
1018
+ recorder.navigated('https://example.test/start')
1019
+ request_sent('r1', 'GET', 'https://example.test/start', started_ms)
1020
+ request_sent('r1', 'GET', 'https://example.test/', started_ms + 1, hop: 1)
1021
+ bidi.emit('network.responseCompleted', 'redirectCount' => 0, 'timestamp' => started_ms + 1,
1022
+ 'request' => { 'request' => 'r1' }, 'response' => { 'status' => 302 })
1023
+ bidi.emit('network.responseCompleted', 'redirectCount' => 1, 'timestamp' => started_ms + 206,
1024
+ 'request' => { 'request' => 'r1' }, 'response' => { 'status' => 200 })
1025
+
1026
+ expect(kept(recorder)['network'].map { |entry| entry.values_at('url', 'status', 'duration_ms') }).to eq(
1027
+ [['https://example.test/start', 302, 1], ['https://example.test/', 200, 205]]
1028
+ )
1029
+ end
1030
+
1031
+ it 'drops one malformed BiDi event, warns once and keeps tracing' do
1032
+ recorder = started_recorder
1033
+ bidi.emit('network.beforeRequestSent', 'request' => 'string', 'timestamp' => started_ms)
1034
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'bad', 'timings' => 'string' },
1035
+ 'response' => 'string', 'timestamp' => started_ms)
1036
+ bidi.emit('log.entryAdded', 'level' => 'info', 'text' => 'bad', 'timestamp' => started_ms, 'stackTrace' => 'string')
1037
+ request_sent('r1', 'GET', 'https://example.test/ok', started_ms)
1038
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r1' }, 'timestamp' => started_ms + 5,
1039
+ 'response' => { 'status' => 200 })
1040
+ bidi.emit('log.entryAdded', 'level' => 'info', 'text' => 'good', 'timestamp' => started_ms)
1041
+ trace = kept(recorder)
1042
+
1043
+ expect(trace['network'].map { |entry| [entry['url'], entry['status']] }).to include(['https://example.test/ok', 200])
1044
+ expect(trace['console'].map { |entry| entry['text'] }).to include('good')
1045
+ expect(TraceHelper.enabled?).to be(true)
1046
+ expect(warnings.size).to eq(1)
1047
+ expect(warnings.first).to match(/dropped a malformed BiDi .* event/)
1048
+ end
1049
+
1050
+ describe 'hardening against hostile event content' do
1051
+ it 'keeps tracing and records no duration for a timing or timestamp that is not finite' do
1052
+ recorder = started_recorder
1053
+ recorder.navigated('https://example.test/login')
1054
+ request_sent('r1', 'GET', 'https://example.test/a', started_ms)
1055
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r1' }, 'timestamp' => Float::INFINITY,
1056
+ 'response' => { 'status' => 200 })
1057
+ request_sent('r2', 'GET', 'https://example.test/b', Float::INFINITY)
1058
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r2', 'timings' => { 'timeOrigin' => Float::INFINITY, 'responseEnd' => 5 } },
1059
+ 'timestamp' => started_ms, 'response' => { 'status' => 200 })
1060
+ bidi.emit('log.entryAdded', 'level' => 'info', 'text' => 'inf', 'timestamp' => Float::INFINITY)
1061
+ trace = kept(recorder)
1062
+
1063
+ expect(TraceHelper.enabled?).to be(true)
1064
+ expect(trace['network'].map { |entry| entry.values_at('url', 'status', 'duration_ms') })
1065
+ .to eq([['https://example.test/a', 200, nil], ['https://example.test/b', 200, nil]])
1066
+ expect(trace['console'].map { |entry| entry['text'] }).to eq(['inf'])
1067
+ end
1068
+
1069
+ it 'drops, and records nothing for, an event whose request is not an object with a string id' do
1070
+ recorder = started_recorder
1071
+ recorder.navigated('https://example.test/login')
1072
+ bidi.emit('network.beforeRequestSent', 'request' => 'string', 'timestamp' => started_ms)
1073
+ bidi.emit('network.beforeRequestSent', 'request' => { 'request' => 123, 'url' => 'https://example.test/x' }, 'timestamp' => started_ms)
1074
+ bidi.emit('network.responseCompleted', 'request' => 'string', 'timestamp' => started_ms, 'response' => { 'status' => 200 })
1075
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 123 }, 'timestamp' => started_ms, 'response' => { 'status' => 200 })
1076
+ bidi.emit('network.fetchError', 'request' => 'string', 'errorText' => 'x', 'timestamp' => started_ms)
1077
+ bidi.emit('network.fetchError', 'request' => { 'request' => 123 }, 'errorText' => 'x', 'timestamp' => started_ms)
1078
+ pending = recorder.instance_variable_get(:@requests).size + recorder.instance_variable_get(:@early).size
1079
+
1080
+ expect(pending).to eq(0)
1081
+ expect(kept(recorder)['network']).to eq([])
1082
+ expect(TraceHelper.enabled?).to be(true)
1083
+ expect(warnings.size).to eq(1)
1084
+ end
1085
+
1086
+ it 'treats a redirectCount that is not an Integer as 0' do
1087
+ recorder = started_recorder
1088
+ recorder.navigated('https://example.test/login')
1089
+ request_sent('r1', 'GET', 'https://example.test/a', started_ms, hop: '7')
1090
+ bidi.emit('network.responseCompleted', 'redirectCount' => 0, 'request' => { 'request' => 'r1' }, 'timestamp' => started_ms + 4,
1091
+ 'response' => { 'status' => 200 })
1092
+
1093
+ expect(kept(recorder)['network'].map { |entry| entry.values_at('status', 'duration_ms') }).to eq([[200, 4]])
1094
+ end
1095
+
1096
+ it 'keeps only scalar values for network and console fields' do
1097
+ recorder = started_recorder
1098
+ recorder.navigated('https://example.test/login')
1099
+ bidi.emit('network.beforeRequestSent', 'request' => { 'request' => 'r1', 'method' => ['GET'], 'url' => { 'nested' => 'x' } },
1100
+ 'timestamp' => started_ms)
1101
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r1' }, 'timestamp' => started_ms + 1,
1102
+ 'response' => { 'status' => { 'a' => 1 }, 'mimeType' => ['x'], 'bytesReceived' => { 'b' => 2 } })
1103
+ bidi.emit('network.beforeRequestSent', 'request' => { 'request' => 'r3', 'method' => 'GET', 'url' => 'https://example.test/n' },
1104
+ 'timestamp' => started_ms)
1105
+ bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r3' }, 'timestamp' => started_ms + 1,
1106
+ 'response' => { 'status' => 200, 'bytesReceived' => Float::INFINITY })
1107
+ bidi.emit('network.beforeRequestSent', 'request' => { 'request' => 'r2', 'method' => 'GET', 'url' => 'https://example.test/e' },
1108
+ 'timestamp' => started_ms)
1109
+ bidi.emit('network.fetchError', 'request' => { 'request' => 'r2' }, 'errorText' => { 'deep' => 'x' }, 'timestamp' => started_ms + 1)
1110
+ bidi.emit('log.entryAdded', 'level' => { 'a' => 1 }, 'text' => %w[a b], 'timestamp' => started_ms)
1111
+ trace = kept(recorder)
1112
+
1113
+ expect(trace['network'].map { |entry| entry.values_at('method', 'url', 'status', 'mime_type', 'bytes', 'error') })
1114
+ .to eq([[nil, nil, nil, nil, nil, nil], ['GET', 'https://example.test/n', 200, nil, nil, nil],
1115
+ ['GET', 'https://example.test/e', nil, nil, nil, nil]])
1116
+ expect(trace['console'].map { |entry| entry.values_at('level', 'text') }).to eq([[nil, nil]])
1117
+ end
1118
+
1119
+ it 'caps the pending requests and the early completions, dropping the oldest' do
1120
+ recorder = started_recorder
1121
+ cap = TraceHelper::Recorder::MAX_PENDING
1122
+ (cap + 5).times do |index|
1123
+ recorder.network_started("s#{index}", { url: "https://example.test/#{index}" })
1124
+ recorder.network_completed("e#{index}", { status: 200 })
1125
+ end
1126
+ requests = recorder.instance_variable_get(:@requests)
1127
+ early = recorder.instance_variable_get(:@early)
1128
+
1129
+ expect([requests.size, early.size]).to eq([cap, cap])
1130
+ expect(requests.keys.first).to eq('s5')
1131
+ expect(early.keys.first).to eq('e5')
1132
+ expect(TraceHelper.enabled?).to be(true)
1133
+ end
1134
+
1135
+ it 'warns about a dropped event even after another notice, and the notice is not hidden by it' do
1136
+ TraceHelper.warn_once('something left out')
1137
+ recorder = started_recorder
1138
+ bidi.emit('network.beforeRequestSent', 'request' => 'string', 'timestamp' => started_ms)
1139
+ bidi.emit('network.beforeRequestSent', 'request' => 'string', 'timestamp' => started_ms)
1140
+ TraceHelper.warn_once('another')
1141
+
1142
+ expect(warnings.size).to eq(2)
1143
+ expect(warnings.first).to eq("[trace] something left out\n")
1144
+ expect(warnings.last).to match(/dropped a malformed BiDi network.beforeRequestSent event/)
1145
+ expect(recorder).not_to be_nil
1146
+ expect(TraceHelper.enabled?).to be(true)
1147
+ end
1148
+
1149
+ it 'warns once about a dropped event before any other notice, leaving that notice free' do
1150
+ started_recorder
1151
+ bidi.emit('log.entryAdded', 'level' => 'info', 'text' => 'x', 'timestamp' => 1, 'stackTrace' => 'string')
1152
+ TraceHelper.warn_once('something left out')
1153
+
1154
+ expect(warnings.size).to eq(2)
1155
+ expect(warnings.last).to eq("[trace] something left out\n")
1156
+ end
1157
+ end
1158
+
783
1159
  it 'records capabilities network/console false and warns once when subscribe raises' do
784
1160
  bidi.failure = Selenium::WebDriver::Error::WebDriverError.new('no such frame')
785
1161
  traces = Array.new(2) do
@@ -846,6 +1222,14 @@ describe 'Generated TraceHelper' do
846
1222
  .to eq([true, true, true, true, false, false])
847
1223
  end
848
1224
 
1225
+ context 'with BIDI=off' do
1226
+ let(:env) { { 'BIDI' => 'off' } }
1227
+
1228
+ it 'is false for every browser' do
1229
+ expect(TraceHelper.bidi?('chrome')).to be(false)
1230
+ end
1231
+ end
1232
+
849
1233
  context 'with TRACE=off' do
850
1234
  let(:env) { { 'TRACE' => 'off' } }
851
1235
 
@@ -872,7 +1256,7 @@ describe 'Generated TraceHelper' do
872
1256
 
873
1257
  def load_driver_helper
874
1258
  generator = HelpersGenerator.new(%w[selenium rspec proj])
875
- %w[driver_helper video_helper].each do |name|
1259
+ %w[driver_helper video_helper network_helper screenshot_helper].each do |name|
876
1260
  source = File.read(File.expand_path("../../lib/generators/templates/helpers/#{name}.tt", __dir__))
877
1261
  File.write(File.join(project_dir, "#{name}.rb"), ERB.new(source, trim_mode: '-').result(generator.instance_eval { binding }))
878
1262
  end
@@ -906,7 +1290,7 @@ describe 'Generated TraceHelper' do
906
1290
  before { load_driver_helper }
907
1291
 
908
1292
  after do
909
- %i[DriverHelper VideoHelper].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
1293
+ %i[DriverHelper VideoHelper NetworkHelper].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
910
1294
  $LOADED_FEATURES.delete(trace_feature)
911
1295
  end
912
1296
 
@@ -925,8 +1309,8 @@ describe 'Generated TraceHelper' do
925
1309
  .to eq([['navigate', 'https://example.test/login'], ['navigate', 'https://example.test/login']])
926
1310
  end
927
1311
 
928
- context 'with VIDEO=off and TRACE=off' do
929
- let(:env) { { 'VIDEO' => 'off', 'TRACE' => 'off' } }
1312
+ context 'with VIDEO=off, TRACE=off and BIDI=off' do
1313
+ let(:env) { { 'VIDEO' => 'off', 'TRACE' => 'off', 'BIDI' => 'off' } }
930
1314
 
931
1315
  it 'passes no listener at all' do
932
1316
  expect(created_arguments.keys).to eq([:options])
@@ -937,9 +1321,17 @@ describe 'Generated TraceHelper' do
937
1321
  end
938
1322
  end
939
1323
 
940
- it 'sets web_socket_url only for chrome, edge and firefox and not with TRACE=off' do
1324
+ it 'sets web_socket_url only for chrome, edge and firefox' do
941
1325
  expect(%w[chrome edge firefox safari].map { |browser| options_for(browser).web_socket_url }).to eq([true, true, true, nil])
942
1326
  end
1327
+
1328
+ context 'with TRACE=off' do
1329
+ let(:env) { { 'TRACE' => 'off' } }
1330
+
1331
+ it 'still sets web_socket_url for the network mocks' do
1332
+ expect(options_for('chrome').web_socket_url).to be(true)
1333
+ end
1334
+ end
943
1335
  end
944
1336
  end
945
1337
  # rubocop:enable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations, RSpec/ExampleLength