cyperful 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cyperful.rb CHANGED
@@ -2,6 +2,8 @@ require "capybara"
2
2
  require "listen"
3
3
 
4
4
  module Cyperful
5
+ ROOT_DIR = File.expand_path("..", __dir__)
6
+
5
7
  @current = nil
6
8
 
7
9
  def self.current
@@ -11,7 +13,7 @@ module Cyperful
11
13
  puts "Setting up Cyperful for: #{test_class}##{test_name}"
12
14
 
13
15
  # must set `Cyperful.current` before calling `async_setup`
14
- @current ||= Cyperful::SystemSteps.new
16
+ @current ||= Cyperful::Driver.new
15
17
  @current.set_current_test(test_class, test_name)
16
18
 
17
19
  nil
@@ -47,9 +49,6 @@ module Cyperful
47
49
  end
48
50
  end
49
51
 
50
- require "cyperful/test_parser"
51
- require "cyperful/ui_server"
52
-
53
52
  class Cyperful::AbstractCommand < StandardError
54
53
  end
55
54
  class Cyperful::ResetCommand < Cyperful::AbstractCommand
@@ -57,419 +56,7 @@ end
57
56
  class Cyperful::ExitCommand < Cyperful::AbstractCommand
58
57
  end
59
58
 
60
- class Cyperful::SystemSteps
61
- attr_reader :steps, :pausing
62
-
63
- SCREENSHOTS_DIR = File.expand_path("../public/screenshots", __dir__)
64
-
65
- def initialize
66
- @step_pausing_queue = Queue.new
67
-
68
- @session = Capybara.current_session
69
- raise "Could not find Capybara session" unless @session
70
-
71
- setup_api_server
72
- end
73
-
74
- def set_current_test(test_class, test_name)
75
- @test_class = test_class
76
- @test_name = test_name.to_sym
77
-
78
- @source_filepath =
79
- Object.const_source_location(test_class.name).first ||
80
- (raise "Could not find source file for #{test_class.name}")
81
-
82
- reset_steps
83
-
84
- print_steps
85
-
86
- @session.visit(@cyperful_origin)
87
- drive_iframe
88
-
89
- # after we setup our UI, send the initialization data
90
- notify_updated_steps
91
-
92
- setup_tracing
93
-
94
- setup_file_listener
95
-
96
- # Sanity check
97
- unless @step_pausing_queue.empty?
98
- raise "step_pausing_queue is not empty during setup"
99
- end
100
-
101
- # Wait for the user to click "Start"
102
- step_pausing_dequeue
103
- end
104
-
105
- def step_pausing_dequeue
106
- command = @step_pausing_queue.deq
107
- if command == :reset
108
- raise Cyperful::ResetCommand
109
- elsif command == :exit
110
- raise Cyperful::ExitCommand
111
- elsif command == :next
112
- # just continue
113
- else
114
- raise "unknown command: #{command}"
115
- end
116
- end
117
-
118
- def reset_steps
119
- # TODO: memoize this when there's multiple tests per file
120
- @steps =
121
- Cyperful::TestParser.new(@test_class).steps_per_test.fetch(@test_name)
122
-
123
- editor = "vscode" # TODO: support other editors?
124
-
125
- @steps.each_with_index do |step, i|
126
- step.merge!(
127
- index: i,
128
- status: "pending",
129
- start_at: nil,
130
- end_at: nil,
131
- paused_at: nil,
132
- permalink: "#{editor}://file/#{@source_filepath}:#{step.fetch(:line)}",
133
- )
134
- end
135
-
136
- @step_per_line = @steps.index_by { |step| step[:line] }
137
-
138
- @current_step = nil
139
-
140
- @pause_at_step = true
141
-
142
- @test_result = nil
143
-
144
- # reset SCREENSHOTS_DIR
145
- FileUtils.rm_rf(SCREENSHOTS_DIR)
146
- FileUtils.mkdir_p(SCREENSHOTS_DIR)
147
- end
148
-
149
- # subscribe to the execution of each line of code in the test.
150
- # this let's us notify the frontend of the line's status, and pause execution if needed.
151
- def setup_tracing
152
- @tracepoint&.disable
153
-
154
- @tracepoint =
155
- TracePoint.new(:line) do |tp|
156
- next if @source_filepath.nil? || tp.path != @source_filepath
157
-
158
- finish_current_step
159
-
160
- step = @step_per_line[tp.lineno]
161
- pause_on_step(step) if step
162
- end
163
- @tracepoint.enable
164
- end
165
-
166
- # Every time a file changes the `test/` directory,
167
- # reset this test
168
- # TODO: add an option to auto-run
169
- def setup_file_listener
170
- test_dir = @source_filepath.match(%r{^/.+/(test|spec)\b})[0]
171
-
172
- @file_listener&.stop
173
- @file_listener =
174
- Listen.to(test_dir) do |_modified, _added, _removed|
175
- puts "Test files changed, resetting test..."
176
-
177
- @pause_at_step = true
178
- @step_pausing_queue.enq(:reset)
179
- end
180
- @file_listener.start
181
- end
182
-
183
- def print_steps
184
- puts "#{@steps.length} steps:"
185
- @steps.each do |step|
186
- puts " #{step[:method]}: #{step[:line]}:#{step[:column]}"
187
- end
188
- puts
189
- end
190
-
191
- # pending (i.e. test hasn't started), paused, running, passed, failed
192
- def test_status
193
- return @test_result[:status] if @test_result # passed or failed
194
-
195
- if @pause_at_step
196
- return "running" if @steps.any? { |step| step[:status] == "running" }
197
-
198
- return "pending" unless @current_step
199
- return "paused"
200
- end
201
-
202
- "running"
203
- end
204
-
205
- def test_duration_ms
206
- start_at = @steps.first&.[](:start_at)
207
- return nil unless start_at
208
- last_ended_step_i = @steps.rindex { |step| step[:end_at] }
209
- return nil unless last_ended_step_i
210
-
211
- end_at = @steps[last_ended_step_i][:end_at]
212
-
213
- duration = end_at - start_at
214
-
215
- @steps.each_with_index do |step, i|
216
- next if i == 0 || i > last_ended_step_i
217
- if step[:paused_at] && step[:start_at]
218
- duration -= (step[:start_at] - step[:paused_at])
219
- end
220
- end
221
-
222
- duration
223
- end
224
-
225
- def steps_updated_data
226
- status = self.test_status
227
- {
228
- event: "steps_updated",
229
- steps: @steps,
230
- current_step_index: @current_step&.[](:index),
231
- pause_at_step: @pause_at_step,
232
- test_suite: @test_class.name,
233
- test_name: @test_name,
234
- test_status: status,
235
- test_error: @test_result&.[](:error)&.to_s,
236
- test_duration_ms: test_duration_ms,
237
- }
238
- end
239
-
240
- private def notify_updated_steps
241
- @ui_server.notify(steps_updated_data)
242
- end
243
-
244
- private def finish_current_step(error = nil)
245
- if @current_step
246
- @current_step[:end_at] = (Time.now.to_f * 1000.0).to_i
247
- @current_step[:status] = !error ? "passed" : "failed"
248
-
249
- # take screenshot after the step has finished
250
- # path = File.join(SCREENSHOTS_DIR, "#{@current_step[:index]}.png")
251
-
252
- # FIXME: this adds ~200ms to each step! disabling it for now
253
- # @session.save_screenshot(path)
254
-
255
- # this adds ~70ms to each step, but causes a weird flash on the screen
256
- # @session.find(:css, "body").base.native.save_screenshot(path)
257
-
258
- @current_step = nil
259
- end
260
-
261
- notify_updated_steps
262
- end
263
-
264
- def pause_on_step(step)
265
- @current_step = step
266
-
267
- puts "STEP: #{step[:as_string]}"
268
-
269
- if @pause_at_step == true || @pause_at_step == step[:index]
270
- @current_step[:paused_at] = (Time.now.to_f * 1000.0).to_i
271
- @current_step[:status] = "paused"
272
- notify_updated_steps
273
-
274
- # async wait for `continue_next_step`
275
- step_pausing_dequeue
276
- end
277
-
278
- @current_step[:status] = "running"
279
- @current_step[:start_at] = (Time.now.to_f * 1000.0).to_i
280
- notify_updated_steps
281
- end
282
-
283
- private def continue_next_step
284
- @step_pausing_queue.enq(:next)
285
- end
286
-
287
- def drive_iframe
288
- puts "Driving iframe..."
289
-
290
- @session.switch_to_frame(
291
- @session.find(:css, "iframe#scenario-frame"), # waits for the iframe to load
292
- )
293
- @driving_iframe = true
294
- end
295
-
296
- # forked from: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/session.rb#L264
297
- private def make_absolute_url(visit_uri)
298
- visit_uri = ::Addressable::URI.parse(visit_uri.to_s)
299
- base_uri =
300
- ::Addressable::URI.parse(@session.config.app_host || @session.server_url)
301
-
302
- if base_uri && [nil, "http", "https"].include?(visit_uri.scheme)
303
- if visit_uri.relative?
304
- visit_uri_parts = visit_uri.to_hash.compact
305
-
306
- # Useful to people deploying to a subdirectory
307
- # and/or single page apps where only the url fragment changes
308
- visit_uri_parts[:path] = base_uri.path + visit_uri.path
309
-
310
- visit_uri = base_uri.merge(visit_uri_parts)
311
- end
312
- # adjust_server_port(visit_uri)
313
- end
314
-
315
- abs_url = visit_uri.to_s
316
-
317
- display_url = abs_url.sub(base_uri.to_s, "")
318
-
319
- [abs_url, display_url]
320
- end
321
-
322
- WATCHER_JS = File.read(File.join(__dir__, "../watcher.js"))
323
-
324
- def internal_visit(url)
325
- return false unless @driving_iframe
326
-
327
- abs_url, display_url = make_absolute_url(url)
328
-
329
- # show the actual `visit` url as soon as it's computed
330
- if @current_step && @current_step[:method] == :visit
331
- @current_step[:as_string] = "visit #{display_url.to_json}"
332
- notify_updated_steps
333
- end
334
-
335
- @session.execute_script("window.location.href = #{abs_url.to_json}")
336
-
337
- # inject the watcher script into the page being tested.
338
- # this script will notify the Cyperful UI for events like:
339
- # console logs, network requests, client navigations, errors, etc.
340
- @session.execute_script(WATCHER_JS) # ~9ms empirically
341
-
342
- true
343
- end
344
-
345
- def internal_current_url
346
- return nil unless @driving_iframe
347
-
348
- @session.evaluate_script("window.location.href")
349
- end
350
-
351
- def setup_api_server
352
- @ui_server = Cyperful::UiServer.new(port: 3004)
353
-
354
- @cyperful_origin = @ui_server.url_origin
355
-
356
- @ui_server.on_command do |command, params|
357
- case command
358
- when "start"
359
- # one of: integer (index of a step), true (pause at every step), or nil (don't pause)
360
- @pause_at_step = params["pause_at_step"]
361
-
362
- continue_next_step
363
- when "reset"
364
- @pause_at_step = true
365
- @step_pausing_queue.enq(:reset)
366
- when "stop"
367
- @pause_at_step = true # enable pausing
368
- when "exit"
369
- @pause_at_step = true
370
-
371
- # instead of calling `exit` directly, we need to raise a Cyperful::ExitCommand error
372
- # so Minitest can finish it's teardown e.g. to reset the database
373
- @step_pausing_queue.enq(:exit)
374
- else
375
- raise "unknown command: #{command}"
376
- end
377
- end
378
-
379
- @ui_server.start_async
380
-
381
- # The server appears to always stop on it's own,
382
- # so we don't need to stop it within an `at_exit` or `Minitest.after_run`
383
-
384
- puts "Cyperful server started: #{@cyperful_origin}"
385
- end
386
-
387
- def teardown(error = nil)
388
- @tracepoint&.disable
389
- @tracepoint = nil
390
-
391
- @file_listener&.stop
392
- @file_listener = nil
393
-
394
- if error&.is_a?(Cyperful::ResetCommand)
395
- puts "\nPlease ignore the error, we're just resetting the test ;)"
396
-
397
- @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
398
-
399
- at_exit { Minitest.run_one_method(@test_class, @test_name) }
400
- return
401
- end
402
-
403
- return if error&.is_a?(Cyperful::ExitCommand)
404
-
405
- if error
406
- # backtrace = error.backtrace.select { |s| s.include?(@source_filepath) }
407
- backtrace = error.backtrace.slice(0, 4)
408
- warn "\n\nTest failed with error:\n#{error.message}\n#{backtrace.join("\n")}"
409
- end
410
-
411
- @test_result = { status: error ? "failed" : "passed", error: error }
412
-
413
- finish_current_step(error)
414
-
415
- @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
416
-
417
- puts "Cyperful teardown complete. Waiting for command..."
418
- command = @step_pausing_queue.deq
419
- if command == :reset
420
- at_exit { Minitest.run_one_method(@test_class, @test_name) }
421
- end
422
- end
423
- end
424
-
425
- module PrependCapybaraSession
426
- # we need to override the following methods because they
427
- # control the top-level browser window, but we want them
428
- # to control the iframe instead
429
-
430
- def visit(url)
431
- return if Cyperful.current&.internal_visit(url)
432
- super
433
- end
434
-
435
- def current_url
436
- url = Cyperful.current&.internal_current_url
437
- return url if url
438
- super
439
- end
440
-
441
- def refresh
442
- return if Cyperful.current&.internal_visit(current_url)
443
- super
444
- end
445
- end
446
- Capybara::Session.prepend(PrependCapybaraSession)
447
-
448
- module Cyperful::SystemTestHelper
449
- def setup
450
- Cyperful.setup(self.class, self.method_name)
451
- super
452
- end
453
-
454
- def teardown
455
- error = passed? ? nil : failure
456
-
457
- error = error.error if error.is_a?(Minitest::UnexpectedError)
458
-
459
- Cyperful.teardown(error)
460
- super
461
- end
462
- end
463
-
464
- # we need to allow the iframe to be embedded in the cyperful server
465
- # TODO: use Rack middleware instead to support non-Rails apps
466
- if const_defined?(:Rails)
467
- Rails.application.config.content_security_policy do |policy|
468
- policy.frame_ancestors(:self, "localhost:3004")
469
- end
470
- else
471
- warn "Cyperful: Rails not detected, skipping content_security_policy"
472
- end
473
-
474
- # fix for: Set-Cookie (SameSite=Lax) doesn't work when within an iframe with host 127.0.0.1
475
- Capybara.server_host = "localhost"
59
+ require "cyperful/test_parser"
60
+ require "cyperful/ui_server"
61
+ require "cyperful/driver"
62
+ require "cyperful/framework_injections"
Binary file