cyperful 0.1.0 → 0.1.1
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 +4 -4
- data/lib/cyperful/driver.rb +376 -0
- data/lib/cyperful/framework_injections.rb +52 -0
- data/lib/cyperful/ui_server.rb +3 -3
- data/lib/cyperful.rb +7 -420
- metadata +7 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e580a31f09fc53e3d99ccbd3b3899d187d61f87eaba1793bf86483660e4c4c72
|
4
|
+
data.tar.gz: 89d517bd16f53772d3ea02b27bf0a195f7540958ee67b77ad8b2435b2cd8fe7c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fdb95faa6ec8a26130cbfb93e834d200efb5a570907ec958e8361ed14de1956564472bc0b0a63617e518c154da5c0df2d8606ae9abf4484d382bb61daa2fe2b4
|
7
|
+
data.tar.gz: 531765dde46ad082559782f4413883ab956b0d9b1b01fe634e723140d5cc15d6b6bbeb88188ad7b5e5091a4f69539876b3be07ee421f414a8c2b54344415cbbd
|
@@ -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"
|
data/lib/cyperful/ui_server.rb
CHANGED
@@ -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.
|
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
|
-
|
56
|
+
raise "websockets already open: #{open_sockets}. You probably need to restart."
|
57
57
|
end
|
58
58
|
|
59
59
|
sock_num = sock_num_counter
|
data/lib/cyperful.rb
CHANGED
@@ -2,6 +2,8 @@ require "capybara"
|
|
2
2
|
require "listen"
|
3
3
|
|
4
4
|
module Cyperful
|
5
|
+
ROOT_DIR = File.expand_path("..", __dir__)
|
6
|
+
|
5
7
|
@current = nil
|
6
8
|
|
7
9
|
def self.current
|
@@ -11,7 +13,7 @@ module Cyperful
|
|
11
13
|
puts "Setting up Cyperful for: #{test_class}##{test_name}"
|
12
14
|
|
13
15
|
# must set `Cyperful.current` before calling `async_setup`
|
14
|
-
@current ||= Cyperful::
|
16
|
+
@current ||= Cyperful::Driver.new
|
15
17
|
@current.set_current_test(test_class, test_name)
|
16
18
|
|
17
19
|
nil
|
@@ -47,9 +49,6 @@ module Cyperful
|
|
47
49
|
end
|
48
50
|
end
|
49
51
|
|
50
|
-
require "cyperful/test_parser"
|
51
|
-
require "cyperful/ui_server"
|
52
|
-
|
53
52
|
class Cyperful::AbstractCommand < StandardError
|
54
53
|
end
|
55
54
|
class Cyperful::ResetCommand < Cyperful::AbstractCommand
|
@@ -57,419 +56,7 @@ end
|
|
57
56
|
class Cyperful::ExitCommand < Cyperful::AbstractCommand
|
58
57
|
end
|
59
58
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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"
|
59
|
+
require "cyperful/test_parser"
|
60
|
+
require "cyperful/ui_server"
|
61
|
+
require "cyperful/driver"
|
62
|
+
require "cyperful/framework_injections"
|
metadata
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: cyperful
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
-
|
7
|
+
- Wyatt Ades
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
@@ -59,11 +59,14 @@ extensions: []
|
|
59
59
|
extra_rdoc_files: []
|
60
60
|
files:
|
61
61
|
- lib/cyperful.rb
|
62
|
+
- lib/cyperful/driver.rb
|
63
|
+
- lib/cyperful/framework_injections.rb
|
62
64
|
- lib/cyperful/test_parser.rb
|
63
65
|
- lib/cyperful/ui_server.rb
|
64
66
|
- watcher.js
|
65
|
-
homepage:
|
66
|
-
licenses:
|
67
|
+
homepage: https://github.com/stepful/cyperful
|
68
|
+
licenses:
|
69
|
+
- MIT
|
67
70
|
metadata: {}
|
68
71
|
post_install_message:
|
69
72
|
rdoc_options: []
|