capydash 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.
@@ -0,0 +1,175 @@
1
+ require 'eventmachine'
2
+ require 'em-websocket'
3
+
4
+ module CapyDash
5
+ class DashboardServer
6
+ attr_reader :port, :clients
7
+
8
+ # Provide a single shared instance for the whole process
9
+ def self.instance(port: nil)
10
+ configured_port = port || CapyDash.config.server_port
11
+ @instance ||= new(port: configured_port)
12
+ end
13
+
14
+ def initialize(port: nil)
15
+ @port = port || CapyDash.config.server_port
16
+ @clients = []
17
+ @history = [] # recent events to replay on reconnect
18
+ @history_limit = CapyDash.config.message_history_limit
19
+ @max_connections = CapyDash.config.max_connections
20
+ end
21
+
22
+ # Start the WebSocket server in a background thread
23
+ def start
24
+ return if defined?(@started) && @started
25
+ @started = true
26
+ Thread.new do
27
+ EM.run do
28
+ EM::WebSocket.run(host: "0.0.0.0", port: @port) do |ws|
29
+
30
+ ws.onopen do
31
+ begin
32
+ # Check connection limit
33
+ if @clients.length >= @max_connections
34
+ CapyDash::Logger.warn("Connection limit reached", {
35
+ current_connections: @clients.length,
36
+ max_connections: @max_connections
37
+ })
38
+ ws.close
39
+ return
40
+ end
41
+
42
+ @clients << ws
43
+ CapyDash::Logger.info("Client connected", {
44
+ total_clients: @clients.length,
45
+ client_id: ws.object_id
46
+ })
47
+ puts "Client connected"
48
+
49
+ # Replay recent history so refreshed clients see last events
50
+ @history.each do |msg|
51
+ begin
52
+ ws.send(msg)
53
+ rescue => e
54
+ CapyDash::ErrorHandler.handle_websocket_error(e, ws)
55
+ end
56
+ end
57
+ rescue => e
58
+ CapyDash::ErrorHandler.handle_websocket_error(e, ws)
59
+ end
60
+ end
61
+
62
+ ws.onclose do
63
+ begin
64
+ @clients.delete(ws)
65
+ CapyDash::Logger.info("Client disconnected", {
66
+ remaining_clients: @clients.length,
67
+ client_id: ws.object_id
68
+ })
69
+ puts "Client disconnected"
70
+ rescue => e
71
+ CapyDash::ErrorHandler.handle_websocket_error(e, ws)
72
+ end
73
+ end
74
+
75
+ ws.onmessage do |msg|
76
+ begin
77
+ CapyDash::Logger.debug("Received WebSocket message", {
78
+ message_length: msg.length,
79
+ client_id: ws.object_id
80
+ })
81
+ puts "Received message: #{msg}"
82
+
83
+ data = JSON.parse(msg) rescue nil
84
+ if data && data["command"] == "run_tests"
85
+ args = data["args"] || ["bundle", "exec", "rails", "test", "test/system"]
86
+ CapyDash::Logger.info("Executing test command", {
87
+ command: args.join(' '),
88
+ client_id: ws.object_id
89
+ })
90
+ puts "[CapyDash] Running command: #{args.join(' ')}"
91
+
92
+ Thread.new do
93
+ begin
94
+ # Change to the dummy app directory and run tests there
95
+ current_dir = File.dirname(__FILE__)
96
+ gem_root = File.expand_path(File.join(current_dir, "..", ".."))
97
+ dummy_app_path = File.join(gem_root, "spec", "dummy_app")
98
+
99
+ CapyDash::Logger.info("Running tests in directory", {
100
+ directory: dummy_app_path,
101
+ exists: Dir.exist?(dummy_app_path)
102
+ })
103
+ puts "[CapyDash] Running tests in: #{dummy_app_path}"
104
+ puts "[CapyDash] Directory exists: #{Dir.exist?(dummy_app_path)}"
105
+
106
+ Dir.chdir(dummy_app_path) do
107
+ # Set Rails environment and ensure CapyDash is loaded
108
+ ENV["RAILS_ENV"] = "test"
109
+ ENV["CAPYDASH_EXTERNAL_WS"] = "1" # Use external WebSocket mode
110
+
111
+ puts "[CapyDash] Current directory: #{Dir.pwd}"
112
+ puts "[CapyDash] Running: #{args.join(' ')}"
113
+
114
+ # Run the command and capture both stdout and stderr
115
+ IO.popen(args, err: [:child, :out]) do |io|
116
+ io.each_line do |line|
117
+ puts "[CapyDash] Test output: #{line.strip}"
118
+ event = { type: "runner", line: line.strip, status: "running", ts: Time.now.to_i }
119
+ broadcast(event.to_json)
120
+ end
121
+ end
122
+ end
123
+ rescue => e
124
+ CapyDash::ErrorHandler.handle_test_execution_error(e, args.join(' '))
125
+ broadcast({ type: "runner", line: "Error: #{e.message}", status: "failed", ts: Time.now.to_i }.to_json)
126
+ end
127
+ broadcast({ type: "runner", line: "Finished", status: "passed", ts: Time.now.to_i }.to_json)
128
+ end
129
+ else
130
+ # Optionally broadcast to all clients
131
+ broadcast(msg)
132
+ end
133
+ rescue => e
134
+ CapyDash::ErrorHandler.handle_websocket_error(e, ws)
135
+ warn "[CapyDash] ws.onmessage error: #{e.message}"
136
+ puts "[CapyDash] Backtrace: #{e.backtrace.first(5).join("\n")}"
137
+ end
138
+ end
139
+
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ # Send a message to all connected clients
146
+ def broadcast(message)
147
+ begin
148
+ # store in history buffer
149
+ @history << message
150
+ @history.shift if @history.length > @history_limit
151
+
152
+ # fan out to all connected clients
153
+ @clients.each do |client|
154
+ begin
155
+ client.send(message)
156
+ rescue => e
157
+ CapyDash::ErrorHandler.handle_websocket_error(e, client)
158
+ # Remove dead connections
159
+ @clients.delete(client)
160
+ end
161
+ end
162
+ rescue => e
163
+ CapyDash::ErrorHandler.handle_error(e, {
164
+ error_type: 'broadcast',
165
+ message_length: message&.length
166
+ })
167
+ end
168
+ end
169
+
170
+ # Stop the WebSocket server gracefully
171
+ def stop
172
+ EM.stop_event_loop if EM.reactor_running?
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,54 @@
1
+ module CapyDash
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace CapyDash
4
+
5
+ rake_tasks do
6
+ load File.expand_path("../tasks/capydash.rake", __dir__)
7
+ end
8
+
9
+ initializer "capydash.configuration", before: :load_config_initializers do
10
+ # Load configuration
11
+ CapyDash.config = CapyDash::Configuration.load_from_file
12
+ CapyDash::Logger.setup(CapyDash.config)
13
+
14
+ CapyDash::Logger.info("CapyDash configuration loaded", {
15
+ server_port: CapyDash.config.server_port,
16
+ log_level: CapyDash.config.log_level,
17
+ auth_enabled: CapyDash.config.auth_enabled?
18
+ })
19
+ end
20
+
21
+ initializer "capydash.instrumentation", after: :load_config_initializers do
22
+ begin
23
+ # Hook into Capybara sessions automatically
24
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
25
+ Capybara::Session.include(CapyDash::Instrumentation)
26
+ end
27
+
28
+ # Start WebSocket server automatically in dev/test unless using external WS
29
+ if (Rails.env.development? || Rails.env.test?) && ENV["CAPYDASH_EXTERNAL_WS"] != "1"
30
+ port = CapyDash.config.server_port
31
+ CapyDash::DashboardServer.instance(port: port).start
32
+
33
+ CapyDash::Logger.info("WebSocket server started", {
34
+ port: port,
35
+ environment: Rails.env
36
+ })
37
+ puts "[CapyDash] WebSocket server started on ws://localhost:#{port}"
38
+ else
39
+ CapyDash::Logger.info("Skipping in-process WebSocket server", {
40
+ reason: ENV["CAPYDASH_EXTERNAL_WS"] == "1" ? "external_mode" : "production_environment",
41
+ environment: Rails.env
42
+ })
43
+ puts "[CapyDash] Skipping in-process WebSocket server (external mode)" if ENV["CAPYDASH_EXTERNAL_WS"] == "1"
44
+ end
45
+ rescue => e
46
+ CapyDash::ErrorHandler.handle_error(e, {
47
+ error_type: 'initialization',
48
+ component: 'engine'
49
+ })
50
+ raise
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,101 @@
1
+ module CapyDash
2
+ class ErrorHandler
3
+ class << self
4
+ def handle_error(error, context = {})
5
+ log_error(error, context)
6
+ notify_error(error, context) if should_notify?(error)
7
+ recover_from_error(error, context)
8
+ end
9
+
10
+ def handle_websocket_error(error, connection = nil)
11
+ context = {
12
+ error_type: 'websocket',
13
+ connection_id: connection&.id,
14
+ error_class: error.class.name
15
+ }
16
+ handle_error(error, context)
17
+ end
18
+
19
+ def handle_test_execution_error(error, test_path = nil)
20
+ context = {
21
+ error_type: 'test_execution',
22
+ test_path: test_path,
23
+ error_class: error.class.name
24
+ }
25
+ handle_error(error, context)
26
+ end
27
+
28
+ def handle_instrumentation_error(error, method_name = nil)
29
+ context = {
30
+ error_type: 'instrumentation',
31
+ method_name: method_name,
32
+ error_class: error.class.name
33
+ }
34
+ handle_error(error, context)
35
+ end
36
+
37
+ private
38
+
39
+ def log_error(error, context)
40
+ Logger.error(
41
+ "Error occurred: #{error.message}",
42
+ context.merge(
43
+ backtrace: error.backtrace&.first(5),
44
+ error_class: error.class.name
45
+ )
46
+ )
47
+ end
48
+
49
+ def notify_error(error, context)
50
+ # In a production app, this would send notifications to monitoring services
51
+ # like Sentry, Bugsnag, or custom webhooks
52
+ puts "🚨 CRITICAL ERROR: #{error.message}"
53
+ puts "Context: #{context}"
54
+ end
55
+
56
+ def should_notify?(error)
57
+ # Only notify for critical errors
58
+ error.is_a?(StandardError) &&
59
+ !error.is_a?(ArgumentError) &&
60
+ !error.is_a?(NoMethodError)
61
+ end
62
+
63
+ def recover_from_error(error, context)
64
+ case context[:error_type]
65
+ when 'websocket'
66
+ recover_websocket_connection
67
+ when 'test_execution'
68
+ recover_test_execution
69
+ when 'instrumentation'
70
+ recover_instrumentation
71
+ else
72
+ # Generic recovery
73
+ Logger.warn("No specific recovery strategy for error type: #{context[:error_type]}")
74
+ end
75
+ end
76
+
77
+ def recover_websocket_connection
78
+ Logger.info("Attempting to recover WebSocket connection")
79
+ # In a real implementation, this would attempt to reconnect
80
+ # or notify the dashboard to refresh the connection
81
+ end
82
+
83
+ def recover_test_execution
84
+ Logger.info("Test execution error recovered")
85
+ # Could implement retry logic or cleanup
86
+ end
87
+
88
+ def recover_instrumentation
89
+ Logger.info("Instrumentation error recovered")
90
+ # Could disable problematic instrumentation or use fallbacks
91
+ end
92
+ end
93
+ end
94
+
95
+ # Custom error classes for better error handling
96
+ class CapyDashError < StandardError; end
97
+ class ConfigurationError < CapyDashError; end
98
+ class WebSocketError < CapyDashError; end
99
+ class TestExecutionError < CapyDashError; end
100
+ class InstrumentationError < CapyDashError; end
101
+ end
@@ -0,0 +1,29 @@
1
+ require 'capydash/dashboard_server'
2
+ require 'capydash/forwarder'
3
+
4
+ module CapyDash
5
+ module EventEmitter
6
+ @clients = []
7
+
8
+ class << self
9
+ attr_accessor :clients
10
+
11
+ def subscribe(&block)
12
+ @on_event = block
13
+ end
14
+
15
+ def broadcast(event)
16
+ @on_event&.call(event)
17
+
18
+ port = CapyDash.configuration&.port
19
+ if ENV["CAPYDASH_EXTERNAL_WS"] == "1"
20
+ forwarder = CapyDash::Forwarder.instance(port: port)
21
+ forwarder.send_message(event)
22
+ else
23
+ server = CapyDash::DashboardServer.instance(port: port)
24
+ server.broadcast(event.to_json)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,84 @@
1
+ require 'eventmachine'
2
+ require 'faye/websocket'
3
+ require 'json'
4
+
5
+ module CapyDash
6
+ # Forwards events to an external WebSocket server when CAPYDASH_EXTERNAL_WS=1
7
+ class Forwarder
8
+ attr_reader :port
9
+
10
+ def self.instance(port: nil)
11
+ configured_port = port || (CapyDash.respond_to?(:configuration) ? CapyDash.configuration&.port : nil)
12
+ @instance ||= new(port: configured_port || 4000)
13
+ end
14
+
15
+ def initialize(port: 4000)
16
+ @port = port
17
+ @connected = false
18
+ @queue = []
19
+ @ws = nil
20
+ @mutex = Mutex.new
21
+ start
22
+ end
23
+
24
+ def start
25
+ return if @started
26
+ @started = true
27
+
28
+ Thread.new do
29
+ EM.run do
30
+ connect
31
+ end
32
+ end
33
+ end
34
+
35
+ def connect
36
+ url = "ws://127.0.0.1:#{@port}"
37
+ puts "[CapyDash Forwarder] Attempting to connect to #{url}"
38
+ @ws = Faye::WebSocket::Client.new(url)
39
+
40
+ @ws.on(:open) do |_event|
41
+ @connected = true
42
+ puts "[CapyDash Forwarder] Connected to WebSocket server"
43
+ flush_queue
44
+ end
45
+
46
+ @ws.on(:close) do |_event|
47
+ @connected = false
48
+ puts "[CapyDash Forwarder] Disconnected from WebSocket server"
49
+ # attempt reconnect after short delay
50
+ EM.add_timer(0.5) { connect }
51
+ end
52
+
53
+ @ws.on(:error) do |event|
54
+ puts "[CapyDash Forwarder] WebSocket error: #{event.inspect}"
55
+ # keep trying; errors are expected if server not yet up
56
+ end
57
+ end
58
+
59
+ def send_message(raw_message)
60
+ message = raw_message.is_a?(String) ? raw_message : JSON.dump(raw_message)
61
+ puts "[CapyDash Forwarder] Sending message: #{message[0..100]}..."
62
+ @mutex.synchronize do
63
+ if @connected && @ws
64
+ @ws.send(message)
65
+ else
66
+ @queue << message
67
+ puts "[CapyDash Forwarder] Queued message (not connected)"
68
+ end
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def flush_queue
75
+ return unless @connected && @ws
76
+ pending = nil
77
+ @mutex.synchronize do
78
+ pending = @queue.dup
79
+ @queue.clear
80
+ end
81
+ pending.each { |m| @ws.send(m) }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,99 @@
1
+ require 'capybara'
2
+ require 'capydash/dashboard_server'
3
+ require 'base64'
4
+ require 'fileutils'
5
+
6
+ puts "[CapyDash] Instrumentation file loaded"
7
+
8
+ module CapyDash
9
+ module Instrumentation
10
+ def visit(path)
11
+ emit_step("visit", path) { super(path) }
12
+ end
13
+
14
+ def click_button(*args)
15
+ emit_step("click_button", args.first) { super(*args) }
16
+ end
17
+
18
+ def fill_in(locator, with:)
19
+ emit_step("fill_in", "#{locator} => #{with}") { super(locator, with: with) }
20
+ end
21
+
22
+ private
23
+
24
+ def emit_step(step_name, detail)
25
+ # take screenshot
26
+ base_dir = if CapyDash.respond_to?(:configuration)
27
+ CapyDash.configuration&.screenshot_path || "tmp/capydash_screenshots"
28
+ else
29
+ "tmp/capydash_screenshots"
30
+ end
31
+ FileUtils.mkdir_p(base_dir) unless Dir.exist?(base_dir)
32
+ timestamp = Time.now.strftime('%Y%m%d-%H%M%S-%L')
33
+ safe_step = step_name.gsub(/\s+/, '_')
34
+ screenshot_path = File.join(base_dir, "#{safe_step}-#{timestamp}.png")
35
+
36
+ data_url = nil
37
+ if defined?(Capybara)
38
+ begin
39
+ current_driver = Capybara.current_driver
40
+ puts "[CapyDash] Current Capybara driver: #{current_driver}"
41
+ if current_driver == :rack_test
42
+ puts "[CapyDash] Skipping screenshot (rack_test driver)"
43
+ else
44
+ if respond_to?(:page)
45
+ page.save_screenshot(screenshot_path)
46
+ else
47
+ Capybara.current_session.save_screenshot(screenshot_path)
48
+ end
49
+ puts "[CapyDash] Saved screenshot: #{screenshot_path}"
50
+ if File.exist?(screenshot_path)
51
+ encoded = Base64.strict_encode64(File.binread(screenshot_path))
52
+ data_url = "data:image/png;base64,#{encoded}"
53
+ end
54
+ end
55
+ rescue => e
56
+ warn "[CapyDash] Screenshot capture failed: #{e.message}"
57
+ end
58
+ end
59
+
60
+ # emit event
61
+ CapyDash::EventEmitter.broadcast(
62
+ step_name: step_name,
63
+ detail: detail,
64
+ screenshot: screenshot_path,
65
+ data_url: data_url,
66
+ test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
67
+ status: "running"
68
+ )
69
+
70
+ # run the original step
71
+ yield
72
+
73
+ # mark success
74
+ CapyDash::EventEmitter.broadcast(
75
+ step_name: step_name,
76
+ detail: detail,
77
+ screenshot: screenshot_path,
78
+ data_url: data_url,
79
+ test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
80
+ status: "passed"
81
+ )
82
+ rescue => e
83
+ CapyDash::EventEmitter.broadcast(
84
+ step_name: step_name,
85
+ detail: detail,
86
+ screenshot: screenshot_path,
87
+ data_url: data_url,
88
+ test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
89
+ status: "failed",
90
+ error: e.message
91
+ )
92
+ raise e
93
+ end
94
+ end
95
+ end
96
+
97
+ # Prepend into Capybara DSL so it wraps all calls
98
+ Capybara::Session.prepend(CapyDash::Instrumentation)
99
+ puts "[CapyDash] Instrumentation prepended into Capybara::Session"
@@ -0,0 +1,99 @@
1
+ require 'logger'
2
+ require 'fileutils'
3
+
4
+ module CapyDash
5
+ class Logger
6
+ class << self
7
+ attr_accessor :instance
8
+ end
9
+
10
+ def initialize(config = nil)
11
+ @config = config || Configuration.new
12
+ @logger = create_logger
13
+ end
14
+
15
+ def self.setup(config = nil)
16
+ self.instance = new(config)
17
+ end
18
+
19
+ def self.info(message, context = {})
20
+ instance&.info(message, context)
21
+ end
22
+
23
+ def self.warn(message, context = {})
24
+ instance&.warn(message, context)
25
+ end
26
+
27
+ def self.error(message, context = {})
28
+ instance&.error(message, context)
29
+ end
30
+
31
+ def self.debug(message, context = {})
32
+ instance&.debug(message, context)
33
+ end
34
+
35
+ def info(message, context = {})
36
+ log(:info, message, context)
37
+ end
38
+
39
+ def warn(message, context = {})
40
+ log(:warn, message, context)
41
+ end
42
+
43
+ def error(message, context = {})
44
+ log(:error, message, context)
45
+ end
46
+
47
+ def debug(message, context = {})
48
+ log(:debug, message, context)
49
+ end
50
+
51
+ private
52
+
53
+ def create_logger
54
+ # Ensure log directory exists
55
+ log_dir = File.dirname(@config.log_file)
56
+ FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
57
+
58
+ # Create logger with file rotation
59
+ logger = ::Logger.new(
60
+ @config.log_file,
61
+ @config.max_files,
62
+ @config.max_file_size
63
+ )
64
+
65
+ # Set log level
66
+ logger.level = case @config.log_level.downcase
67
+ when 'debug' then ::Logger::DEBUG
68
+ when 'info' then ::Logger::INFO
69
+ when 'warn' then ::Logger::WARN
70
+ when 'error' then ::Logger::ERROR
71
+ else ::Logger::INFO
72
+ end
73
+
74
+ # Set format
75
+ logger.formatter = proc do |severity, datetime, progname, msg|
76
+ "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{progname}: #{msg}\n"
77
+ end
78
+
79
+ logger
80
+ end
81
+
82
+ def log(level, message, context = {})
83
+ return unless @logger
84
+
85
+ # Add context information
86
+ full_message = message
87
+ if context.any?
88
+ context_str = context.map { |k, v| "#{k}=#{v}" }.join(' ')
89
+ full_message = "#{message} | #{context_str}"
90
+ end
91
+
92
+ @logger.send(level, full_message)
93
+ rescue => e
94
+ # Fallback to stdout if logging fails
95
+ puts "Logging error: #{e.message}"
96
+ puts "#{level.upcase}: #{message}"
97
+ end
98
+ end
99
+ end