cyperful 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e6cdf194b4606ed00df58d8b28820efb2bcf69f5a3f636ec5fed096fc8ce84e
4
- data.tar.gz: 98ebee3f3b456c6d2fcaa905b19e1408503e1c2341926dd9d2b808c31287ef42
3
+ metadata.gz: c54a2f9266b145a049ddf720e90e50ec1250867ab51b51b046792ec36f57438a
4
+ data.tar.gz: a6dfac785f965f733c9b5fe41ea99c2437972f1a63dbbb346dfa822f6c2f187b
5
5
  SHA512:
6
- metadata.gz: da33579e39387049c628a5d6a1d52dac4d56fffdc754671fd3928d22bb9cdf4149d597637beab80413e68eac719b6ee4607fc88acc39803568365240dac3f2b2
7
- data.tar.gz: b5ee454173523ea078cf592aa2861541522514695bb50c7cdd0a42bb49c9b67359ef5493c55629cd1be2ff7ea3e40946f17e889be009cb27520803f6883ca637
6
+ metadata.gz: 79c77ab5eb6c05c9eb4531964b545ad2580e2a2e8a188835ec7bc5d261087eb05603faaa9ea7fbc16509f4c3e9675c1750bdf96e23c42716e2efcbae93715fab
7
+ data.tar.gz: 23bb98e1e7e6bb5f2939461ecfdf7b66bd5d04f06b449696c7ebacde7b8bf4b69396a48451fc3d3fe5e7aa5f199955bab4beaaadc6524c38c05003678998a0b7
@@ -0,0 +1,376 @@
1
+ class Cyperful::Driver
2
+ attr_reader :steps, :pausing
3
+
4
+ SCREENSHOTS_DIR = File.join(Cyperful::ROOT_DIR, "public/screenshots")
5
+
6
+ def initialize
7
+ @step_pausing_queue = Queue.new
8
+
9
+ @session = Capybara.current_session
10
+ raise "Could not find Capybara session" unless @session
11
+
12
+ setup_api_server
13
+ end
14
+
15
+ def set_current_test(test_class, test_name)
16
+ @test_class = test_class
17
+ @test_name = test_name.to_sym
18
+
19
+ @source_filepath =
20
+ Object.const_source_location(test_class.name).first ||
21
+ (raise "Could not find source file for #{test_class.name}")
22
+
23
+ reset_steps
24
+
25
+ print_steps
26
+
27
+ @session.visit(@cyperful_origin)
28
+ drive_iframe
29
+
30
+ # after we setup our UI, send the initialization data
31
+ notify_updated_steps
32
+
33
+ setup_tracing
34
+
35
+ setup_file_listener
36
+
37
+ # Sanity check
38
+ unless @step_pausing_queue.empty?
39
+ raise "step_pausing_queue is not empty during setup"
40
+ end
41
+
42
+ # Wait for the user to click "Start"
43
+ step_pausing_dequeue
44
+ end
45
+
46
+ def step_pausing_dequeue
47
+ command = @step_pausing_queue.deq
48
+ if command == :reset
49
+ raise Cyperful::ResetCommand
50
+ elsif command == :exit
51
+ raise Cyperful::ExitCommand
52
+ elsif command == :next
53
+ # just continue
54
+ else
55
+ raise "unknown command: #{command}"
56
+ end
57
+ end
58
+
59
+ def reset_steps
60
+ # TODO: memoize this when there's multiple tests per file
61
+ @steps =
62
+ Cyperful::TestParser.new(@test_class).steps_per_test.fetch(@test_name)
63
+
64
+ editor = "vscode" # TODO: support other editors?
65
+
66
+ @steps.each_with_index do |step, i|
67
+ step.merge!(
68
+ index: i,
69
+ status: "pending",
70
+ start_at: nil,
71
+ end_at: nil,
72
+ paused_at: nil,
73
+ permalink: "#{editor}://file/#{@source_filepath}:#{step.fetch(:line)}",
74
+ )
75
+ end
76
+
77
+ @step_per_line = @steps.index_by { |step| step[:line] }
78
+
79
+ @current_step = nil
80
+
81
+ @pause_at_step = true
82
+
83
+ @test_result = nil
84
+
85
+ # reset SCREENSHOTS_DIR
86
+ FileUtils.rm_rf(SCREENSHOTS_DIR)
87
+ FileUtils.mkdir_p(SCREENSHOTS_DIR)
88
+ end
89
+
90
+ def queue_reset
91
+ # FIXME: there may be other tests that are "queued" to run `at_exit`,
92
+ # so they'll run before this test restarts.
93
+ at_exit { Minitest.run_one_method(@test_class, @test_name) }
94
+ end
95
+
96
+ # subscribe to the execution of each line of code in the test.
97
+ # this let's us notify the frontend of the line's status, and pause execution if needed.
98
+ def setup_tracing
99
+ @tracepoint&.disable
100
+
101
+ @tracepoint =
102
+ TracePoint.new(:line) do |tp|
103
+ next if @source_filepath.nil? || tp.path != @source_filepath
104
+
105
+ finish_current_step
106
+
107
+ step = @step_per_line[tp.lineno]
108
+ pause_on_step(step) if step
109
+ end
110
+ @tracepoint.enable
111
+ end
112
+
113
+ # Every time a file changes the `test/` directory,
114
+ # reset this test
115
+ # TODO: add an option to auto-run
116
+ def setup_file_listener
117
+ test_dir = @source_filepath.match(%r{^/.+/(test|spec)\b})[0]
118
+
119
+ @file_listener&.stop
120
+ @file_listener =
121
+ Listen.to(test_dir) do |_modified, _added, _removed|
122
+ puts "Test files changed, resetting test..."
123
+
124
+ @pause_at_step = true
125
+ @step_pausing_queue.enq(:reset)
126
+ end
127
+ @file_listener.start
128
+ end
129
+
130
+ def print_steps
131
+ puts "#{@steps.length} steps:"
132
+ @steps.each do |step|
133
+ puts " #{step[:method]}: #{step[:line]}:#{step[:column]}"
134
+ end
135
+ puts
136
+ end
137
+
138
+ # pending (i.e. test hasn't started), paused, running, passed, failed
139
+ def test_status
140
+ return @test_result[:status] if @test_result # passed or failed
141
+
142
+ if @pause_at_step
143
+ return "running" if @steps.any? { |step| step[:status] == "running" }
144
+
145
+ return "pending" unless @current_step
146
+ return "paused"
147
+ end
148
+
149
+ "running"
150
+ end
151
+
152
+ def test_duration_ms
153
+ start_at = @steps.first&.[](:start_at)
154
+ return nil unless start_at
155
+ last_ended_step_i = @steps.rindex { |step| step[:end_at] }
156
+ return nil unless last_ended_step_i
157
+
158
+ end_at = @steps[last_ended_step_i][:end_at]
159
+
160
+ duration = end_at - start_at
161
+
162
+ @steps.each_with_index do |step, i|
163
+ next if i == 0 || i > last_ended_step_i
164
+ if step[:paused_at] && step[:start_at]
165
+ duration -= (step[:start_at] - step[:paused_at])
166
+ end
167
+ end
168
+
169
+ duration
170
+ end
171
+
172
+ def steps_updated_data
173
+ status = self.test_status
174
+ {
175
+ event: "steps_updated",
176
+ steps: @steps,
177
+ current_step_index: @current_step&.[](:index),
178
+ pause_at_step: @pause_at_step,
179
+ test_suite: @test_class.name,
180
+ test_name: @test_name,
181
+ test_status: status,
182
+ test_error: @test_result&.[](:error)&.to_s,
183
+ test_duration_ms: test_duration_ms,
184
+ }
185
+ end
186
+
187
+ private def notify_updated_steps
188
+ @ui_server.notify(steps_updated_data)
189
+ end
190
+
191
+ private def finish_current_step(error = nil)
192
+ if @current_step
193
+ @current_step[:end_at] = (Time.now.to_f * 1000.0).to_i
194
+ @current_step[:status] = !error ? "passed" : "failed"
195
+
196
+ # take screenshot after the step has finished
197
+ # path = File.join(SCREENSHOTS_DIR, "#{@current_step[:index]}.png")
198
+
199
+ # FIXME: this adds ~200ms to each step! disabling it for now
200
+ # @session.save_screenshot(path)
201
+
202
+ # this adds ~70ms to each step, but causes a weird flash on the screen
203
+ # @session.find(:css, "body").base.native.save_screenshot(path)
204
+
205
+ @current_step = nil
206
+ end
207
+
208
+ notify_updated_steps
209
+ end
210
+
211
+ def pause_on_step(step)
212
+ @current_step = step
213
+
214
+ puts "STEP: #{step[:as_string]}"
215
+
216
+ if @pause_at_step == true || @pause_at_step == step[:index]
217
+ @current_step[:paused_at] = (Time.now.to_f * 1000.0).to_i
218
+ @current_step[:status] = "paused"
219
+ notify_updated_steps
220
+
221
+ # async wait for `continue_next_step`
222
+ step_pausing_dequeue
223
+ end
224
+
225
+ @current_step[:status] = "running"
226
+ @current_step[:start_at] = (Time.now.to_f * 1000.0).to_i
227
+ notify_updated_steps
228
+ end
229
+
230
+ private def continue_next_step
231
+ @step_pausing_queue.enq(:next)
232
+ end
233
+
234
+ def drive_iframe
235
+ puts "Driving iframe..."
236
+
237
+ @session.switch_to_frame(
238
+ @session.find(:css, "iframe#scenario-frame"), # waits for the iframe to load
239
+ )
240
+ @driving_iframe = true
241
+ end
242
+
243
+ # forked from: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/session.rb#L264
244
+ private def make_absolute_url(visit_uri)
245
+ visit_uri = ::Addressable::URI.parse(visit_uri.to_s)
246
+ base_uri =
247
+ ::Addressable::URI.parse(@session.config.app_host || @session.server_url)
248
+
249
+ if base_uri && [nil, "http", "https"].include?(visit_uri.scheme)
250
+ if visit_uri.relative?
251
+ visit_uri_parts = visit_uri.to_hash.compact
252
+
253
+ # Useful to people deploying to a subdirectory
254
+ # and/or single page apps where only the url fragment changes
255
+ visit_uri_parts[:path] = base_uri.path + visit_uri.path
256
+
257
+ visit_uri = base_uri.merge(visit_uri_parts)
258
+ end
259
+ # adjust_server_port(visit_uri)
260
+ end
261
+
262
+ abs_url = visit_uri.to_s
263
+
264
+ display_url = abs_url.sub(base_uri.to_s, "")
265
+
266
+ [abs_url, display_url]
267
+ end
268
+
269
+ WATCHER_JS = File.read(File.join(Cyperful::ROOT_DIR, "watcher.js"))
270
+
271
+ def internal_visit(url)
272
+ return false unless @driving_iframe
273
+
274
+ abs_url, display_url = make_absolute_url(url)
275
+
276
+ # show the actual `visit` url as soon as it's computed
277
+ if @current_step && @current_step[:method] == :visit
278
+ @current_step[:as_string] = "visit #{display_url.to_json}"
279
+ notify_updated_steps
280
+ end
281
+
282
+ @session.execute_script("window.location.href = #{abs_url.to_json}")
283
+
284
+ # inject the watcher script into the page being tested.
285
+ # this script will notify the Cyperful UI for events like:
286
+ # console logs, network requests, client navigations, errors, etc.
287
+ @session.execute_script(WATCHER_JS) # ~9ms empirically
288
+
289
+ true
290
+ end
291
+
292
+ def internal_current_url
293
+ return nil unless @driving_iframe
294
+
295
+ @session.evaluate_script("window.location.href")
296
+ end
297
+
298
+ def setup_api_server
299
+ @ui_server = Cyperful::UiServer.new(port: 3004)
300
+
301
+ @cyperful_origin = @ui_server.url_origin
302
+
303
+ @ui_server.on_command do |command, params|
304
+ case command
305
+ when "start"
306
+ # one of: integer (index of a step), true (pause at every step), or nil (don't pause)
307
+ @pause_at_step = params["pause_at_step"]
308
+
309
+ continue_next_step
310
+ when "reset"
311
+ @pause_at_step = true
312
+ @step_pausing_queue.enq(:reset)
313
+ when "stop"
314
+ @pause_at_step = true # enable pausing
315
+ when "exit"
316
+ @pause_at_step = true
317
+
318
+ # instead of calling `exit` directly, we need to raise a Cyperful::ExitCommand error
319
+ # so Minitest can finish it's teardown e.g. to reset the database
320
+ @step_pausing_queue.enq(:exit)
321
+ else
322
+ raise "unknown command: #{command}"
323
+ end
324
+ end
325
+
326
+ @ui_server.start_async
327
+
328
+ # The server appears to always stop on it's own,
329
+ # so we don't need to stop it within an `at_exit` or `Minitest.after_run`
330
+
331
+ puts "Cyperful server started: #{@cyperful_origin}"
332
+ end
333
+
334
+ def teardown(error = nil)
335
+ @tracepoint&.disable
336
+ @tracepoint = nil
337
+
338
+ @file_listener&.stop
339
+ @file_listener = nil
340
+
341
+ if error&.is_a?(Cyperful::ResetCommand)
342
+ puts "\nPlease ignore the error, we're just resetting the test ;)"
343
+
344
+ @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
345
+
346
+ queue_reset
347
+ return
348
+ end
349
+
350
+ return if error&.is_a?(Cyperful::ExitCommand)
351
+
352
+ if error
353
+ # get the 4 lines following the first line that includes the source file
354
+ i = nil
355
+ backtrace =
356
+ error.backtrace.select do |s|
357
+ i ||= 0 if s.include?(@source_filepath)
358
+ i += 1 if i
359
+ break if i && i > 4
360
+ true
361
+ end
362
+
363
+ warn "\n\nTest failed with error:\n#{error.message}\n#{backtrace.join("\n")}"
364
+ end
365
+
366
+ @test_result = { status: error ? "failed" : "passed", error: error }
367
+
368
+ finish_current_step(error)
369
+
370
+ @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
371
+
372
+ puts "Cyperful teardown complete. Waiting for command..."
373
+ command = @step_pausing_queue.deq
374
+ queue_reset if command == :reset
375
+ end
376
+ end
@@ -0,0 +1,52 @@
1
+ # we need to override the some Capybara::Session methods because they
2
+ # control the top-level browser window, but we want them
3
+ # to control the iframe instead
4
+ module PrependCapybaraSession
5
+ def visit(url)
6
+ return if Cyperful.current&.internal_visit(url)
7
+ super
8
+ end
9
+
10
+ def current_url
11
+ url = Cyperful.current&.internal_current_url
12
+ return url if url
13
+ super
14
+ end
15
+
16
+ def refresh
17
+ return if Cyperful.current&.internal_visit(current_url)
18
+ super
19
+ end
20
+ end
21
+ Capybara::Session.prepend(PrependCapybaraSession)
22
+
23
+ # The Minitest test helper.
24
+ # TODO: support other test frameworks like RSpec
25
+ module Cyperful::SystemTestHelper
26
+ def setup
27
+ Cyperful.setup(self.class, self.method_name)
28
+ super
29
+ end
30
+
31
+ def teardown
32
+ error = passed? ? nil : failure
33
+
34
+ error = error.error if error.is_a?(Minitest::UnexpectedError)
35
+
36
+ Cyperful.teardown(error)
37
+ super
38
+ end
39
+ end
40
+
41
+ # we need to allow the iframe to be embedded in the cyperful server
42
+ # TODO: use Rack middleware instead to support non-Rails apps
43
+ if defined?(Rails)
44
+ Rails.application.config.content_security_policy do |policy|
45
+ policy.frame_ancestors(:self, "localhost:3004")
46
+ end
47
+ else
48
+ warn "Cyperful: Rails not detected, skipping content_security_policy fix.\nThe Cyperful UI may not work correctly."
49
+ end
50
+
51
+ # fix for: Set-Cookie (SameSite=Lax) doesn't work when within an iframe with host 127.0.0.1
52
+ Capybara.server_host = "localhost"
@@ -1,8 +1,8 @@
1
1
  require "webrick/websocket"
2
2
 
3
+ # fix for: webrick-websocket incorrectly assumes `Upgrade` header is always present
3
4
  module FixWebrickWebsocketServer
4
5
  def service(req, res)
5
- # fix for: webrick-websocket incorrectly assumes `Upgrade` header is always present
6
6
  req.header["upgrade"] = [""] if req["upgrade"].nil?
7
7
  super
8
8
  end
@@ -34,7 +34,7 @@ class Cyperful::UiServer
34
34
  @server =
35
35
  WEBrick::Websocket::HTTPServer.new(
36
36
  Port: @port,
37
- DocumentRoot: File.expand_path("../../public", __dir__),
37
+ DocumentRoot: File.join(Cyperful::ROOT_DIR, "public"),
38
38
  Logger: WEBrick::Log.new("/dev/null"),
39
39
  AccessLog: [],
40
40
  )
@@ -53,7 +53,7 @@ class Cyperful::UiServer
53
53
  # at the moment it's not possible to have more than one client connected.
54
54
  # TODO: handle the client unexpectedly disconnecting e.g. user changes browser url
55
55
  if open_sockets.length > 0
56
- warn "Warning: websockets already open: #{open_sockets}. You probably need to restart."
56
+ raise "websockets already open: #{open_sockets}. You probably need to restart."
57
57
  end
58
58
 
59
59
  sock_num = sock_num_counter