cyperful 0.1.4 → 0.1.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61dce63e0bff592ded11835cf202b241d717acaaf99331db8f447d3d7dc2aba9
4
- data.tar.gz: a2ec8532880c523e33ce15d377a1fc28b6832354306c784fa3dea7a38200302b
3
+ metadata.gz: 83254b2335dc0d99b443c2aad2260adc7c4f56bebaf32847a9e4ce151c03c6bc
4
+ data.tar.gz: 5a444d60e56c014569604e91162943aed5d6cb4f21655aaff8405be62d67b622
5
5
  SHA512:
6
- metadata.gz: 0cd35d0e7f3ee879367682ef2780fd6904d879a2d0a6c95a936191c3054b9cbfe60f682d4b911804565050b4da8a75744f5f8f58ef41d8fe82c02e1de84278de
7
- data.tar.gz: 5755db30366159e25279fde63c4e2ccc2f94c934dee4538b237b8e3aee05774ff0182ee8e006d21aaf525ed0b8bdb75675a2fdbeb2879d0ab8fe66d92ecbd4ff
6
+ metadata.gz: 945bad502b63c646fd55f43a997fbb2442d27866600aaac1aeda1aabd2c218841f51a81a000ecb0ede704097bd0e53d4ad67d9b8aa9c5b501ce62dc445d1c3cf
7
+ data.tar.gz: 32ce9c41a512ebc499b1852d5cf584fdf9b86015906da4c154706f056a5c385cd341aa174388c2b3171b82c1a17f23bd012ccaa62f6382d86c070a7f91dbd467
@@ -0,0 +1,19 @@
1
+ module Cyperful
2
+ # @abstract
3
+ class AbstractCommand < StandardError
4
+ # don't print normal error/backtrace
5
+ def to_s
6
+ command_name =
7
+ self.class.name.split("::").last.chomp("Command").underscore
8
+ "(Captured cyperful command: #{command_name})"
9
+ end
10
+ def backtrace
11
+ []
12
+ end
13
+ end
14
+
15
+ class ResetCommand < AbstractCommand
16
+ end
17
+ class ExitCommand < AbstractCommand
18
+ end
19
+ end
@@ -18,7 +18,7 @@ class Cyperful::Driver
18
18
 
19
19
  @source_filepath =
20
20
  Object.const_source_location(test_class.name).first ||
21
- (raise "Could not find source file for #{test_class.name}")
21
+ raise("Could not find source file for #{test_class.name}")
22
22
 
23
23
  reset_steps
24
24
 
@@ -36,11 +36,11 @@ class Cyperful::Driver
36
36
 
37
37
  # Sanity check
38
38
  unless @step_pausing_queue.empty?
39
- raise "step_pausing_queue is not empty during setup"
39
+ raise "Unexpected: step_pausing_queue must be empty during setup"
40
40
  end
41
41
 
42
42
  # Wait for the user to click "Start"
43
- step_pausing_dequeue
43
+ step_pausing_dequeue if @pause_at_step == true
44
44
  end
45
45
 
46
46
  def step_pausing_dequeue
@@ -81,6 +81,11 @@ class Cyperful::Driver
81
81
 
82
82
  @pause_at_step = true
83
83
 
84
+ run_options = self.class.pop_run_options!
85
+ if run_options.key?(:pause_at_step)
86
+ @pause_at_step = run_options[:pause_at_step]
87
+ end
88
+
84
89
  @test_result = nil
85
90
 
86
91
  # reset SCREENSHOTS_DIR
@@ -88,10 +93,39 @@ class Cyperful::Driver
88
93
  FileUtils.mkdir_p(SCREENSHOTS_DIR)
89
94
  end
90
95
 
96
+ @next_run_options = {}
97
+ def self.next_run_options=(options)
98
+ @next_run_options = options
99
+ end
100
+ def self.pop_run_options!
101
+ opts = @next_run_options
102
+ @next_run_options = {}
103
+ opts
104
+ end
105
+
106
+ private def reload_const(class_name, source_path)
107
+ Object.send(:remove_const, class_name) if Object.const_defined?(class_name)
108
+ load(source_path) # reload the file
109
+ unless Object.const_defined?(class_name)
110
+ raise "Failed to reload test class: #{class_name}"
111
+ end
112
+ Object.const_get(class_name)
113
+ end
114
+
91
115
  def queue_reset
92
- # FIXME: there may be other tests that are "queued" to run `at_exit`,
93
- # so they'll run before this test restarts.
94
- at_exit { Minitest.run_one_method(@test_class, @test_name) }
116
+ at_exit do
117
+ # reload test-suite code on reset (for `setup_file_listener`)
118
+ # TODO: also reload dependent files
119
+ # NOTE: run_on_method will fail if test_name also changed
120
+ @test_class = reload_const(@test_class.name, @source_filepath)
121
+
122
+ # TODO
123
+ # if Cyperful.config.reload_source_files && defined?(Rails)
124
+ # Rails.application.reloader.reload!
125
+ # end
126
+
127
+ Minitest.run_one_method(@test_class, @test_name)
128
+ end
95
129
  end
96
130
 
97
131
  # subscribe to the execution of each line of code in the test.
@@ -111,28 +145,41 @@ class Cyperful::Driver
111
145
  @tracepoint.enable
112
146
  end
113
147
 
148
+ private def test_directory
149
+ @source_filepath.match(%r{^/.+/(?:test|spec)\b})&.[](0) ||
150
+ raise("Could not determine test directory for #{@source_filepath}")
151
+ end
152
+
114
153
  # Every time a file changes the `test/` directory, reset this test
115
154
  # TODO: add an option to auto-run on reload
116
155
  def setup_file_listener
117
- # TODO: we need to somehow reload the source files
118
-
119
- # test_dir = @source_filepath.match(%r{^/.+/(?:test|spec)\b})[0]
120
-
121
- # @file_listener&.stop
122
- # @file_listener =
123
- # Listen.to(test_dir) do |_modified, _added, _removed|
124
- # puts "Test files changed, resetting test..."
125
-
126
- # @pause_at_step = true
127
- # @step_pausing_queue.enq(:reset)
128
- # end
129
- # @file_listener.start
156
+ # TODO
157
+ # if Cyperful.config.reload_source_files
158
+ # @source_file_listener = Listen.to(rails_directory) ...
159
+ # end
160
+
161
+ if Cyperful.config.reload_test_files
162
+ @file_listener&.stop
163
+ @file_listener =
164
+ Listen.to(test_directory) do |_modified, _added, _removed|
165
+ puts "Test files changed, resetting test..."
166
+
167
+ # keep the same pause state after the reload
168
+ self.class.next_run_options = { pause_at_step: @pause_at_step }
169
+
170
+ @pause_at_step = true # pause current test immediately
171
+ @step_pausing_queue.enq(:reset)
172
+ end
173
+ @file_listener.start
174
+ end
130
175
  end
131
176
 
132
177
  def print_steps
133
- puts "#{@steps.length} steps:"
134
- @steps.each do |step|
135
- puts " #{step[:method]}: #{step[:line]}:#{step[:column]}"
178
+ puts "Found #{@steps.length} steps:"
179
+ @steps.each_with_index do |step, i|
180
+ puts " #{
181
+ (i + 1).to_s.rjust(2)
182
+ }. #{step[:method]}: #{step[:line]}:#{step[:column]}"
136
183
  end
137
184
  puts
138
185
  end
@@ -190,11 +237,39 @@ class Cyperful::Driver
190
237
  @ui_server.notify(steps_updated_data)
191
238
  end
192
239
 
240
+ # called at the start of each step
241
+ def pause_on_step(step)
242
+ @current_step = step
243
+
244
+ # using `print` so we can append the step's status (see `finish_current_step`)
245
+ print("STEP #{(step[:index] + 1).to_s.rjust(2)}: #{step[:as_string]}")
246
+
247
+ if @pause_at_step == true || @pause_at_step == step[:index]
248
+ @current_step[:paused_at] = (Time.now.to_f * 1000.0).to_i
249
+ @current_step[:status] = "paused"
250
+ notify_updated_steps
251
+
252
+ # async wait for `continue_next_step`
253
+ step_pausing_dequeue
254
+ end
255
+
256
+ @current_step[:status] = "running"
257
+ @current_step[:start_at] = (Time.now.to_f * 1000.0).to_i
258
+ notify_updated_steps
259
+ end
260
+
261
+ # called at the end of each step
193
262
  private def finish_current_step(error = nil)
194
263
  if @current_step
195
264
  @current_step[:end_at] = (Time.now.to_f * 1000.0).to_i
196
265
  @current_step[:status] = !error ? "passed" : "failed"
197
266
 
267
+ puts(
268
+ " (#{@current_step[:end_at] - @current_step[:start_at]}ms)#{
269
+ error ? " FAILED" : ""
270
+ }",
271
+ )
272
+
198
273
  # take screenshot after the step has finished
199
274
  # path = File.join(SCREENSHOTS_DIR, "#{@current_step[:index]}.png")
200
275
 
@@ -210,25 +285,6 @@ class Cyperful::Driver
210
285
  notify_updated_steps
211
286
  end
212
287
 
213
- def pause_on_step(step)
214
- @current_step = step
215
-
216
- puts "STEP: #{step[:as_string]}"
217
-
218
- if @pause_at_step == true || @pause_at_step == step[:index]
219
- @current_step[:paused_at] = (Time.now.to_f * 1000.0).to_i
220
- @current_step[:status] = "paused"
221
- notify_updated_steps
222
-
223
- # async wait for `continue_next_step`
224
- step_pausing_dequeue
225
- end
226
-
227
- @current_step[:status] = "running"
228
- @current_step[:start_at] = (Time.now.to_f * 1000.0).to_i
229
- notify_updated_steps
230
- end
231
-
232
288
  private def continue_next_step
233
289
  @step_pausing_queue.enq(:next)
234
290
  end
@@ -283,7 +339,15 @@ class Cyperful::Driver
283
339
  [abs_url, display_url]
284
340
  end
285
341
 
286
- WATCHER_JS = File.read(File.join(Cyperful::ROOT_DIR, "watcher.js"))
342
+ def self.load_frame_agent_js
343
+ return @frame_agent_js if defined?(@frame_agent_js)
344
+
345
+ @frame_agent_js =
346
+ File.read(File.join(Cyperful::ROOT_DIR, "public/frame-agent.js")).sub(
347
+ "__CYPERFUL_CONFIG__",
348
+ { CYPERFUL_ORIGIN: "http://localhost:#{Cyperful.config.port}" }.to_json,
349
+ )
350
+ end
287
351
 
288
352
  private def skip_multi_sessions
289
353
  unless Capybara.current_session == @session
@@ -307,10 +371,10 @@ class Cyperful::Driver
307
371
 
308
372
  @session.execute_script("window.location.href = #{abs_url.to_json}")
309
373
 
310
- # inject the watcher script into the page being tested.
374
+ # inject the frame-agent script into the page being tested.
311
375
  # this script will notify the Cyperful UI for events like:
312
376
  # console logs, network requests, client navigations, errors, etc.
313
- @session.execute_script(WATCHER_JS) # ~9ms empirically
377
+ @session.execute_script(Cyperful::Driver.load_frame_agent_js) # ~9ms empirically
314
378
 
315
379
  true
316
380
  end
@@ -323,7 +387,7 @@ class Cyperful::Driver
323
387
  end
324
388
 
325
389
  def setup_api_server
326
- @ui_server = Cyperful::UiServer.new(port: 3004)
390
+ @ui_server = Cyperful::UiServer.new(port: Cyperful.config.port)
327
391
 
328
392
  @cyperful_origin = @ui_server.url_origin
329
393
 
@@ -363,7 +427,7 @@ class Cyperful::Driver
363
427
  @tracepoint = nil
364
428
 
365
429
  if error&.is_a?(Cyperful::ResetCommand)
366
- puts "\nPlease ignore the error, we're just resetting the test ;)"
430
+ puts "\nResetting test (ignore any error logs)..."
367
431
 
368
432
  @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
369
433
 
@@ -395,6 +459,7 @@ class Cyperful::Driver
395
459
  @ui_server.notify(nil) # `break` out of the `loop` (see `UiServer#socket_open`)
396
460
 
397
461
  puts "Cyperful teardown complete. Waiting for command..."
462
+ # NOTE: this will raise an `Interrupt` if the user Ctrl+C's here
398
463
  command = @step_pausing_queue.deq
399
464
  queue_reset if command == :reset
400
465
  ensure
@@ -1,3 +1,5 @@
1
+ require "action_dispatch/system_testing/driver"
2
+
1
3
  # we need to override the some Capybara::Session methods because they
2
4
  # control the top-level browser window, but we want them
3
5
  # to control the iframe instead
@@ -51,13 +53,48 @@ module Cyperful::SystemTestHelper
51
53
  Cyperful.teardown(error)
52
54
  super
53
55
  end
56
+
57
+ # Disable default screenshot on failure b/c we handle them ourselves.
58
+ # https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb#L156
59
+ def take_failed_screenshot
60
+ nil
61
+ end
62
+ end
63
+
64
+ module PrependSystemTestingDriver
65
+ def initialize(...)
66
+ super(...)
67
+
68
+ prev_capabilities = @capabilities
69
+ @capabilities =
70
+ proc do |driver_opts|
71
+ prev_capabilities&.call(driver_opts)
72
+
73
+ next unless driver_opts.respond_to?(:add_argument)
74
+
75
+ # this assumes Selenium and Chrome:
76
+
77
+ # so user isn't prompted when we start recording video w/ MediaStream
78
+ driver_opts.add_argument("--auto-accept-this-tab-capture")
79
+ driver_opts.add_argument("--use-fake-ui-for-media-stream")
80
+
81
+ # make sure we're not in headless mode
82
+ driver_opts.args.delete("--headless")
83
+ driver_opts.args.delete("--headless=new")
84
+ end
85
+ end
54
86
  end
87
+ ActionDispatch::SystemTesting::Driver.prepend(PrependSystemTestingDriver)
88
+
89
+ # if defined?(Minitest::Test)
90
+ # Minitest::Test::PASSTHROUGH_EXCEPTIONS << Cyperful::AbstractCommand
91
+ # end
55
92
 
56
93
  # we need to allow the iframe to be embedded in the cyperful server
57
94
  # TODO: use Rack middleware instead to support non-Rails apps
58
95
  if defined?(Rails)
59
96
  Rails.application.config.content_security_policy do |policy|
60
- policy.frame_ancestors(:self, "localhost:3004")
97
+ policy.frame_ancestors(:self, "localhost:#{Cyperful.config.port}")
61
98
  end
62
99
  else
63
100
  warn "Cyperful: Rails not detected, skipping content_security_policy fix.\nThe Cyperful UI may not work correctly."
@@ -74,7 +74,7 @@ class Cyperful::TestParser
74
74
  test_string = node.children[0].children[2].children[0]
75
75
 
76
76
  # https://github.com/rails/rails/blob/66676ce499a32e4c62220bd05f8ee2cdf0e15f0c/activesupport/lib/active_support/testing/declarative.rb#L14C23-L14C61
77
- test_method = "test_#{test_string.gsub(/\s+/, "_")}".to_sym
77
+ test_method = :"test_#{test_string.gsub(/\s+/, "_")}"
78
78
 
79
79
  block_node = node.children[2]
80
80
  [test_method, block_node]
@@ -106,6 +106,18 @@ class Cyperful::UiServer
106
106
 
107
107
  res.status = 204
108
108
  end
109
+
110
+ @server.mount_proc("/api/config") do |req, res|
111
+ if req.request_method != "GET"
112
+ res.body = "Only POST allowed"
113
+ res.status = 405
114
+ next
115
+ end
116
+
117
+ res.body = Cyperful.config.to_h.to_json
118
+ res["Content-Type"] = "application/json"
119
+ res.status = 200
120
+ end
109
121
  end
110
122
 
111
123
  def start_async
data/lib/cyperful.rb CHANGED
@@ -6,6 +6,27 @@ module Cyperful
6
6
 
7
7
  @current = nil
8
8
 
9
+ class Config < Struct.new(
10
+ :port,
11
+ :auto_run_on_reload,
12
+ :reload_test_files,
13
+ # :reload_source_files, # not implemented yet
14
+ :history_recording, # EXPERIMENTAL
15
+ keyword_init: true,
16
+ )
17
+ def initialize
18
+ super(
19
+ port: 3004,
20
+ auto_run_on_reload: true,
21
+ reload_test_files: true,
22
+ history_recording: true,
23
+ )
24
+ end
25
+ end
26
+ def self.config
27
+ @config ||= Config.new
28
+ end
29
+
9
30
  def self.current
10
31
  @current
11
32
  end
@@ -34,13 +55,7 @@ module Cyperful
34
55
  end
35
56
  end
36
57
 
37
- class Cyperful::AbstractCommand < StandardError
38
- end
39
- class Cyperful::ResetCommand < Cyperful::AbstractCommand
40
- end
41
- class Cyperful::ExitCommand < Cyperful::AbstractCommand
42
- end
43
-
58
+ require "cyperful/commands"
44
59
  require "cyperful/test_parser"
45
60
  require "cyperful/ui_server"
46
61
  require "cyperful/driver"
@@ -0,0 +1 @@
1
+ *,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.static{position:static}.absolute{position:absolute}.relative{position:relative}.bottom-1{bottom:.25rem}.left-0{left:0}.left-1{left:.25rem}.right-0{right:0}.right-1{right:.25rem}.right-4{right:1rem}.top-0{top:0}.top-1{top:.25rem}.m-4{margin:1rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.mr-2{margin-right:.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.hidden{display:none}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-16{max-height:4rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-full{width:100%}.flex-1{flex:1 1 0%}.basis-96{flex-basis:24rem}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize{resize:both}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(1rem * var(--tw-space-x-reverse));margin-left:calc(1rem * calc(1 - var(--tw-space-x-reverse)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-blue-600{--tw-border-opacity: 1;border-color:rgb(37 99 235 / var(--tw-border-opacity))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity))}.border-green-600{--tw-border-opacity: 1;border-color:rgb(22 163 74 / var(--tw-border-opacity))}.border-orange-500{--tw-border-opacity: 1;border-color:rgb(249 115 22 / var(--tw-border-opacity))}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity))}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity))}.border-yellow-600{--tw-border-opacity: 1;border-color:rgb(202 138 4 / var(--tw-border-opacity))}.bg-\[\#121b2e\]{--tw-bg-opacity: 1;background-color:rgb(18 27 46 / var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity: 1;background-color:rgb(107 114 128 / var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity))}.bg-orange-400{--tw-bg-opacity: 1;background-color:rgb(251 146 60 / var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity))}.bg-slate-950{--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-4{padding:1rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.pt-0{padding-top:0}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[1\.25em\]{font-size:1.25em}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-green-100{--tw-text-opacity: 1;color:rgb(220 252 231 / var(--tw-text-opacity))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity))}.text-red-100{--tw-text-opacity: 1;color:rgb(254 226 226 / var(--tw-text-opacity))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity))}.opacity-50{opacity:.5}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-inner{--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / .05);--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-\[width\]{transition-property:width;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}@keyframes rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.Logo g{transform-origin:50% 50%;animation:rotate 5s linear infinite;animation-play-state:paused}.Logo.Logo--animating g{animation-play-state:running}.Logo g:nth-child(1){animation-duration:1s}.Logo g:nth-child(2){animation-duration:1.5s}.Logo g:nth-child(3){animation-duration:3s}.Logo g:nth-child(4){animation-duration:4s}.hover\:bg-blue-600:hover{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity))}.hover\:bg-orange-500:hover{--tw-bg-opacity: 1;background-color:rgb(249 115 22 / var(--tw-bg-opacity))}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity))}.hover\:bg-yellow-600:hover{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity))}.hover\:underline:hover{text-decoration-line:underline}.group:hover .group-hover\:block{display:block}