cyperful 0.1.0 → 0.1.2

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