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 +7 -0
- data/lib/cyperful/test_parser.rb +78 -0
- data/lib/cyperful/ui_server.rb +125 -0
- data/lib/cyperful.rb +475 -0
- data/watcher.js +151 -0
- metadata +87 -0
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: []
|