cyperful 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2e6cdf194b4606ed00df58d8b28820efb2bcf69f5a3f636ec5fed096fc8ce84e
4
+ data.tar.gz: 98ebee3f3b456c6d2fcaa905b19e1408503e1c2341926dd9d2b808c31287ef42
5
+ SHA512:
6
+ metadata.gz: da33579e39387049c628a5d6a1d52dac4d56fffdc754671fd3928d22bb9cdf4149d597637beab80413e68eac719b6ee4607fc88acc39803568365240dac3f2b2
7
+ data.tar.gz: b5ee454173523ea078cf592aa2861541522514695bb50c7cdd0a42bb49c9b67359ef5493c55629cd1be2ff7ea3e40946f17e889be009cb27520803f6883ca637
@@ -0,0 +1,78 @@
1
+ require "parser/current"
2
+
3
+ class Cyperful::TestParser
4
+ def initialize(test_class)
5
+ @test_class = test_class
6
+ @source_filepath = Object.const_source_location(test_class.name).first
7
+ end
8
+
9
+ def steps_per_test
10
+ ast = Parser::CurrentRuby.parse(File.read(@source_filepath))
11
+
12
+ test_class_name = @test_class.name.to_sym
13
+
14
+ system_test_class =
15
+ ast.children.find do |node|
16
+ if node.type == :class
17
+ node.children.find do |c|
18
+ c.type == :const && c.children[1] == test_class_name
19
+ end
20
+ end
21
+ end
22
+ unless system_test_class
23
+ raise "Could not find class #{test_class.name} in #{@source_filepath}"
24
+ end
25
+
26
+ (
27
+ # the children of the `class` node are either:
28
+ # - a `begin` node if there's more than 1 child node
29
+ # - or the one 0 or 1 child node
30
+ system_test_class
31
+ .children
32
+ .find { |node| node.type == :begin }
33
+ &.children || [system_test_class.children[2]].compact
34
+ )
35
+ .map do |node|
36
+ # e.g. `test "my test" do ... end`
37
+ if node.type == :block && node.children[0].type == :send &&
38
+ node.children[0].children[1] == :test
39
+ test_string = node.children[0].children[2].children[0]
40
+
41
+ # https://github.com/rails/rails/blob/66676ce499a32e4c62220bd05f8ee2cdf0e15f0c/activesupport/lib/active_support/testing/declarative.rb#L14C23-L14C61
42
+ test_method = "test_#{test_string.gsub(/\s+/, "_")}".to_sym
43
+
44
+ block_node = node.children[2]
45
+ [test_method, block_node]
46
+ else
47
+ # e.g. `def test_my_test; ... end`
48
+ # TODO
49
+ end
50
+ end
51
+ .compact
52
+ .to_h do |test_method, block_node|
53
+ [
54
+ test_method,
55
+ find_test_steps(block_node)
56
+ # sanity check:
57
+ .uniq { |step| step[:line] },
58
+ ]
59
+ end
60
+ end
61
+
62
+ private def find_test_steps(ast, out = [])
63
+ return out unless ast&.is_a?(Parser::AST::Node)
64
+
65
+ if ast.type == :send && Cyperful.step_at_methods.include?(ast.children[1])
66
+ out << {
67
+ method: ast.children[1],
68
+ line: ast.loc.line,
69
+ column: ast.loc.column,
70
+ as_string: ast.loc.expression.source,
71
+ }
72
+ end
73
+
74
+ ast.children.each { |child| find_test_steps(child, out) }
75
+
76
+ out
77
+ end
78
+ end
@@ -0,0 +1,125 @@
1
+ require "webrick/websocket"
2
+
3
+ module FixWebrickWebsocketServer
4
+ def service(req, res)
5
+ # fix for: webrick-websocket incorrectly assumes `Upgrade` header is always present
6
+ req.header["upgrade"] = [""] if req["upgrade"].nil?
7
+ super
8
+ end
9
+ end
10
+ WEBrick::Websocket::HTTPServer.prepend(FixWebrickWebsocketServer)
11
+
12
+ class Cyperful::UiServer
13
+ def initialize(port:)
14
+ @port = port
15
+
16
+ @notify_queue = Queue.new
17
+
18
+ build_server
19
+ end
20
+
21
+ def url_origin
22
+ "http://localhost:#{@port}"
23
+ end
24
+
25
+ def notify(data)
26
+ @notify_queue.enq(data)
27
+ end
28
+
29
+ def on_command(&block)
30
+ @on_command = block
31
+ end
32
+
33
+ private def build_server
34
+ @server =
35
+ WEBrick::Websocket::HTTPServer.new(
36
+ Port: @port,
37
+ DocumentRoot: File.expand_path("../../public", __dir__),
38
+ Logger: WEBrick::Log.new("/dev/null"),
39
+ AccessLog: [],
40
+ )
41
+
42
+ notify_queue = @notify_queue
43
+
44
+ sock_num_counter = 0
45
+ open_sockets = []
46
+
47
+ @server.mount(
48
+ "/api/websocket",
49
+ Class.new(WEBrick::Websocket::Servlet) do
50
+ # use `define_method` so we can access outer scope variables i.e. `notify_queue`
51
+ define_method(:socket_open) do |sock|
52
+ # this would be an unexpected state,
53
+ # at the moment it's not possible to have more than one client connected.
54
+ # TODO: handle the client unexpectedly disconnecting e.g. user changes browser url
55
+ if open_sockets.length > 0
56
+ warn "Warning: websockets already open: #{open_sockets}. You probably need to restart."
57
+ end
58
+
59
+ sock_num = sock_num_counter
60
+ sock_num_counter += 1
61
+
62
+ open_sockets << sock_num
63
+ sock.instance_variable_set(:@sock_num, sock_num)
64
+
65
+ # puts "Websocket #{sock_num} opened."
66
+ loop do
67
+ data = notify_queue.deq
68
+
69
+ # puts "Websocket #{sock_num} got: #{data.class.name}"
70
+ break unless data
71
+ sock.puts(data.to_json)
72
+ end
73
+ rescue => err
74
+ warn "Error in websocket #{sock_num}: #{err}"
75
+ sock.close
76
+ end
77
+
78
+ define_method(:socket_close) do |sock|
79
+ sock_num = sock.instance_variable_get(:@sock_num)
80
+
81
+ # puts "Websocket #{sock_num} closed!"
82
+ open_sockets.delete(sock_num)
83
+ end
84
+ end,
85
+ )
86
+
87
+ # should we use websocket events for this?
88
+ @server.mount_proc("/api/steps/command") do |req, res|
89
+ if req.request_method != "POST"
90
+ res.body = "Only POST allowed"
91
+ res.status = 405
92
+ next
93
+ end
94
+
95
+ command, params = JSON.parse(req.body).values_at("command", "params")
96
+
97
+ if @on_command
98
+ begin
99
+ @on_command.call(command, params)
100
+ rescue => err
101
+ res.body = "Error: #{err}"
102
+ res.status = 500
103
+ next
104
+ end
105
+ end
106
+
107
+ res.status = 204
108
+ end
109
+ end
110
+
111
+ def start_async
112
+ # start server in background i.e. non-blocking
113
+ @thread =
114
+ Thread.new do
115
+ Thread.current.abort_on_exception = true
116
+ @server.start
117
+ end
118
+ end
119
+
120
+ def shutdown
121
+ @thread&.kill
122
+
123
+ @server.shutdown
124
+ end
125
+ end
data/lib/cyperful.rb ADDED
@@ -0,0 +1,475 @@
1
+ require "capybara"
2
+ require "listen"
3
+
4
+ module Cyperful
5
+ @current = nil
6
+
7
+ def self.current
8
+ @current
9
+ end
10
+ def self.setup(test_class, test_name)
11
+ puts "Setting up Cyperful for: #{test_class}##{test_name}"
12
+
13
+ # must set `Cyperful.current` before calling `async_setup`
14
+ @current ||= Cyperful::SystemSteps.new
15
+ @current.set_current_test(test_class, test_name)
16
+
17
+ nil
18
+ rescue => err
19
+ unless err.is_a?(Cyperful::AbstractCommand)
20
+ warn "Error setting up Cyperful:\n\n#{err.message}\n#{err.backtrace.slice(0, 4).join("\n")}\n"
21
+ end
22
+
23
+ raise err
24
+ end
25
+
26
+ def self.teardown(error = nil)
27
+ @current&.teardown(error)
28
+ end
29
+
30
+ # more potential methods: https://www.rubydoc.info/github/jnicklas/capybara/Capybara/Session
31
+ @step_at_methods = [*Capybara::Session::NODE_METHODS, :visit, :refresh]
32
+ def self.step_at_methods
33
+ @step_at_methods
34
+ end
35
+ def self.add_step_at_methods(*mods_or_methods)
36
+ mods_or_methods.each do |mod_or_method|
37
+ case mod_or_method
38
+ when Module
39
+ @step_at_methods +=
40
+ mod_or_method.methods(false) + mod_or_method.instance_methods(false)
41
+ when String, Symbol
42
+ @step_at_methods << mod_or_method.to_sym
43
+ else
44
+ raise "Expected Module or Array of strings/symbols, got #{mod_or_method.class}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ require "cyperful/test_parser"
51
+ require "cyperful/ui_server"
52
+
53
+ class Cyperful::AbstractCommand < StandardError
54
+ end
55
+ class Cyperful::ResetCommand < Cyperful::AbstractCommand
56
+ end
57
+ class Cyperful::ExitCommand < Cyperful::AbstractCommand
58
+ end
59
+
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"
data/watcher.js ADDED
@@ -0,0 +1,151 @@
1
+ (() => {
2
+ const log = console.log;
3
+
4
+ log('Cyperful watcher loading...');
5
+
6
+ const CYPERFUL_ORIGIN = 'http://localhost:3004';
7
+
8
+ let idCounter = 0;
9
+ const notify = (type, data, startEvent = null) => {
10
+ let evt;
11
+ try {
12
+ const timestamp = Date.now();
13
+ const id = `${timestamp}-${idCounter++}`;
14
+
15
+ if (data.url != null) {
16
+ try {
17
+ const url = new URL(data.url, window.location.origin);
18
+
19
+ // don't show our own requests
20
+ if (url.origin === CYPERFUL_ORIGIN) return null;
21
+
22
+ if (url.origin === window.location.origin) {
23
+ data.url = url.pathname + url.search + url.hash;
24
+ }
25
+ } catch (_err) {
26
+ // e.g. invalid URL
27
+ }
28
+ }
29
+
30
+ evt = {
31
+ type,
32
+ data,
33
+ id,
34
+ timestamp,
35
+ start_id: startEvent ? startEvent.id : undefined,
36
+ };
37
+
38
+ window.parent.postMessage(evt, CYPERFUL_ORIGIN);
39
+ } catch (_err) {
40
+ // e.g. blocked by CORS
41
+ // e.g. invalid payload
42
+ }
43
+ return evt || {};
44
+ };
45
+
46
+ // capture console logs
47
+ for (const level of ['log', 'error', 'warn', 'info', 'dir', 'debug']) {
48
+ const original = console[level];
49
+ if (!original) continue;
50
+ console[level] = (...args) => {
51
+ original.apply(console, args);
52
+ notify('log', { level, args });
53
+ };
54
+ }
55
+
56
+ // capture global errors
57
+ window.addEventListener('error', (event) => {
58
+ notify('global_error', { message: event.error.toString() });
59
+ });
60
+ window.addEventListener('unhandledrejection', (event) => {
61
+ notify('unhandledrejection', { message: event.reason.toString() });
62
+ });
63
+
64
+ // capture XHR network requests
65
+ const OriginalXHR = window.XMLHttpRequest;
66
+ function XHR() {
67
+ const xhr = new OriginalXHR();
68
+ const originalOpen = xhr.open;
69
+ xhr.open = (...args) => {
70
+ const start = notify('xhr', {
71
+ method: args[0],
72
+ url: args[1],
73
+ // body: args[2],
74
+ });
75
+ xhr.addEventListener('load', () => {
76
+ if (start)
77
+ notify(
78
+ 'xhr:finished',
79
+ { status: xhr.status, response: xhr.response },
80
+ start,
81
+ );
82
+ });
83
+ return originalOpen.apply(this, args);
84
+ };
85
+ return xhr;
86
+ }
87
+ window.XMLHttpRequest = XHR;
88
+
89
+ // capture fetch network requests
90
+ const originalFetch = window.fetch;
91
+ window.fetch = (...args) => {
92
+ const [url, options] =
93
+ typeof args[0] === 'string' ? args : [args[0].url, args[0]];
94
+ const method = options?.method ?? 'GET';
95
+ const body = options?.body;
96
+
97
+ const start = notify('fetch', {
98
+ method,
99
+ url,
100
+ body,
101
+ bodyType:
102
+ options.headers?.['content-type'] ||
103
+ options.headers?.['Content-Type'] ||
104
+ null,
105
+ });
106
+
107
+ const promise = originalFetch(...args);
108
+ promise
109
+ .then(async (response) => {
110
+ const ct = response.headers.get('content-type') || '';
111
+ const resBody = ct.includes('application/json')
112
+ ? await response.clone().json()
113
+ : ct.includes('text/')
114
+ ? await response.clone().text()
115
+ : `[[ Unhandled content-type: ${ct || '<empty>'} ]]`;
116
+
117
+ if (start)
118
+ notify(
119
+ 'fetch:finished',
120
+ {
121
+ status: response.status,
122
+ responseType: ct || null,
123
+ response: resBody,
124
+ },
125
+ start,
126
+ );
127
+ })
128
+ .catch(() => {});
129
+ return promise;
130
+ };
131
+
132
+ // capture client-side location changes
133
+ const originalPushState = history.pushState;
134
+ history.pushState = (...args) => {
135
+ originalPushState.apply(history, args);
136
+ notify('client_navigate', {
137
+ url: location.href,
138
+ replace: false,
139
+ });
140
+ };
141
+ const originalReplaceState = history.replaceState;
142
+ history.replaceState = (...args) => {
143
+ originalReplaceState.apply(history, args);
144
+ notify('client_navigate', {
145
+ url: location.href,
146
+ replace: true,
147
+ });
148
+ };
149
+
150
+ log('Cyperful watcher loaded.');
151
+ })();
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cyperful
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - me@wyattades.com
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-08-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: capybara
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: listen
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webrick-websocket
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.0.3
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.0.3
55
+ description:
56
+ email:
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/cyperful.rb
62
+ - lib/cyperful/test_parser.rb
63
+ - lib/cyperful/ui_server.rb
64
+ - watcher.js
65
+ homepage:
66
+ licenses: []
67
+ metadata: {}
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.4.19
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Cypress-esque testing for Capybara tests
87
+ test_files: []