cyperful 0.1.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.
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: []