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.
- checksums.yaml +7 -0
- data/README.md +286 -0
- data/capydash.gemspec +30 -0
- data/lib/capydash/auth.rb +103 -0
- data/lib/capydash/configuration.rb +186 -0
- data/lib/capydash/dashboard_server.rb +175 -0
- data/lib/capydash/engine.rb +54 -0
- data/lib/capydash/error_handler.rb +101 -0
- data/lib/capydash/event_emitter.rb +29 -0
- data/lib/capydash/forwarder.rb +84 -0
- data/lib/capydash/instrumentation.rb +99 -0
- data/lib/capydash/logger.rb +99 -0
- data/lib/capydash/persistence.rb +139 -0
- data/lib/capydash/version.rb +3 -0
- data/lib/capydash.rb +54 -0
- data/lib/tasks/capydash.rake +11 -0
- metadata +141 -0
@@ -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
|