capydash 0.2.0 → 0.2.2

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.
@@ -1,52 +0,0 @@
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
- else
38
- CapyDash::Logger.info("Skipping in-process WebSocket server", {
39
- reason: ENV["CAPYDASH_EXTERNAL_WS"] == "1" ? "external_mode" : "production_environment",
40
- environment: Rails.env
41
- })
42
- end
43
- rescue => e
44
- CapyDash::ErrorHandler.handle_error(e, {
45
- error_type: 'initialization',
46
- component: 'engine'
47
- })
48
- raise
49
- end
50
- end
51
- end
52
- end
@@ -1,101 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,78 +0,0 @@
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
- @ws = Faye::WebSocket::Client.new(url)
38
-
39
- @ws.on(:open) do |_event|
40
- @connected = true
41
- flush_queue
42
- end
43
-
44
- @ws.on(:close) do |_event|
45
- @connected = false
46
- # attempt reconnect after short delay
47
- EM.add_timer(0.5) { connect }
48
- end
49
-
50
- @ws.on(:error) do |event|
51
- # keep trying; errors are expected if server not yet up
52
- end
53
- end
54
-
55
- def send_message(raw_message)
56
- message = raw_message.is_a?(String) ? raw_message : JSON.dump(raw_message)
57
- @mutex.synchronize do
58
- if @connected && @ws
59
- @ws.send(message)
60
- else
61
- @queue << message
62
- end
63
- end
64
- end
65
-
66
- private
67
-
68
- def flush_queue
69
- return unless @connected && @ws
70
- pending = nil
71
- @mutex.synchronize do
72
- pending = @queue.dup
73
- @queue.clear
74
- end
75
- pending.each { |m| @ws.send(m) }
76
- end
77
- end
78
- end
@@ -1,153 +0,0 @@
1
- require 'capybara'
2
- require 'capydash/dashboard_server'
3
- require 'base64'
4
- require 'fileutils'
5
-
6
- module CapyDash
7
- module Instrumentation
8
- def visit(path)
9
- emit_step("visit", path) { super(path) }
10
- end
11
-
12
- def click_button(*args)
13
- emit_step("click_button", args.first) { super(*args) }
14
- end
15
-
16
- def fill_in(locator, with:)
17
- emit_step("fill_in", "#{locator} => #{with}") { super(locator, with: with) }
18
- end
19
-
20
- private
21
-
22
- def emit_step(step_name, detail)
23
- # emit "running" event first (without screenshot)
24
- CapyDash::EventEmitter.broadcast(
25
- step_name: step_name,
26
- detail: detail,
27
- test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
28
- status: "running"
29
- )
30
-
31
- # run the original step
32
- yield
33
-
34
- # take screenshot AFTER the action is completed
35
- base_dir = if CapyDash.respond_to?(:configuration)
36
- CapyDash.configuration&.screenshot_path || "tmp/capydash_screenshots"
37
- else
38
- "tmp/capydash_screenshots"
39
- end
40
- FileUtils.mkdir_p(base_dir) unless Dir.exist?(base_dir)
41
- timestamp = Time.now.strftime('%Y%m%d-%H%M%S-%L')
42
- safe_step = step_name.gsub(/\s+/, '_')
43
- screenshot_path = File.join(base_dir, "#{safe_step}-#{timestamp}.png")
44
-
45
- # Ensure we have an absolute path for Base64 encoding
46
- absolute_screenshot_path = File.absolute_path(screenshot_path)
47
-
48
- data_url = nil
49
- if defined?(Capybara)
50
- begin
51
- current_driver = Capybara.current_driver
52
- if current_driver != :rack_test
53
- if respond_to?(:page)
54
- page.save_screenshot(screenshot_path)
55
- else
56
- Capybara.current_session.save_screenshot(screenshot_path)
57
- end
58
-
59
- # Try to find the actual screenshot file location
60
- actual_screenshot_path = nil
61
- if File.exist?(absolute_screenshot_path)
62
- actual_screenshot_path = absolute_screenshot_path
63
- else
64
- # Check if it was saved in the capybara tmp directory
65
- capybara_path = File.join(Dir.pwd, "tmp", "capybara", screenshot_path)
66
- if File.exist?(capybara_path)
67
- actual_screenshot_path = capybara_path
68
- end
69
- end
70
-
71
- if actual_screenshot_path && File.exist?(actual_screenshot_path)
72
- encoded = Base64.strict_encode64(File.binread(actual_screenshot_path))
73
- data_url = "data:image/png;base64,#{encoded}"
74
- end
75
- end
76
- rescue => e
77
- # Silently handle screenshot capture failures
78
- end
79
- end
80
-
81
- # mark success with screenshot
82
- CapyDash::EventEmitter.broadcast(
83
- step_name: step_name,
84
- detail: detail,
85
- screenshot: actual_screenshot_path || screenshot_path,
86
- data_url: data_url,
87
- test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
88
- status: "passed"
89
- )
90
- rescue => e
91
- # take screenshot even on failure (after the action was attempted)
92
- base_dir = if CapyDash.respond_to?(:configuration)
93
- CapyDash.configuration&.screenshot_path || "tmp/capydash_screenshots"
94
- else
95
- "tmp/capydash_screenshots"
96
- end
97
- FileUtils.mkdir_p(base_dir) unless Dir.exist?(base_dir)
98
- timestamp = Time.now.strftime('%Y%m%d-%H%M%S-%L')
99
- safe_step = step_name.gsub(/\s+/, '_')
100
- screenshot_path = File.join(base_dir, "#{safe_step}-#{timestamp}.png")
101
-
102
- # Ensure we have an absolute path for Base64 encoding
103
- absolute_screenshot_path = File.absolute_path(screenshot_path)
104
-
105
- data_url = nil
106
- if defined?(Capybara)
107
- begin
108
- current_driver = Capybara.current_driver
109
- if current_driver != :rack_test
110
- if respond_to?(:page)
111
- page.save_screenshot(screenshot_path)
112
- else
113
- Capybara.current_session.save_screenshot(screenshot_path)
114
- end
115
-
116
- # Try to find the actual screenshot file location
117
- actual_screenshot_path = nil
118
- if File.exist?(absolute_screenshot_path)
119
- actual_screenshot_path = absolute_screenshot_path
120
- else
121
- # Check if it was saved in the capybara tmp directory
122
- capybara_path = File.join(Dir.pwd, "tmp", "capybara", screenshot_path)
123
- if File.exist?(capybara_path)
124
- actual_screenshot_path = capybara_path
125
- end
126
- end
127
-
128
- if actual_screenshot_path && File.exist?(actual_screenshot_path)
129
- encoded = Base64.strict_encode64(File.binread(actual_screenshot_path))
130
- data_url = "data:image/png;base64,#{encoded}"
131
- end
132
- end
133
- rescue => e
134
- # Silently handle screenshot capture failures
135
- end
136
- end
137
-
138
- CapyDash::EventEmitter.broadcast(
139
- step_name: step_name,
140
- detail: detail,
141
- screenshot: actual_screenshot_path || screenshot_path,
142
- data_url: data_url,
143
- test_name: (defined?(CapyDash) ? CapyDash.current_test : nil),
144
- status: "failed",
145
- error: e.message
146
- )
147
- raise e
148
- end
149
- end
150
- end
151
-
152
- # Prepend into Capybara DSL so it wraps all calls
153
- Capybara::Session.prepend(CapyDash::Instrumentation)
@@ -1,99 +0,0 @@
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
@@ -1,134 +0,0 @@
1
- require 'json'
2
- require 'fileutils'
3
-
4
- module CapyDash
5
- class Persistence
6
- class << self
7
- def save_test_run(test_run_data)
8
- ensure_data_directory
9
- file_path = test_run_file_path(test_run_data[:id] || generate_run_id)
10
-
11
- begin
12
- File.write(file_path, JSON.pretty_generate(test_run_data))
13
- file_path
14
- rescue => e
15
- ErrorHandler.handle_error(e, {
16
- error_type: 'persistence',
17
- operation: 'save_test_run',
18
- run_id: test_run_data[:id]
19
- })
20
- nil
21
- end
22
- end
23
-
24
- def load_test_run(run_id)
25
- file_path = test_run_file_path(run_id)
26
- return nil unless File.exist?(file_path)
27
-
28
- begin
29
- JSON.parse(File.read(file_path), symbolize_names: true)
30
- rescue => e
31
- ErrorHandler.handle_error(e, {
32
- error_type: 'persistence',
33
- operation: 'load_test_run',
34
- run_id: run_id
35
- })
36
- nil
37
- end
38
- end
39
-
40
- def list_test_runs(limit = 50)
41
- ensure_data_directory
42
- data_dir = data_directory
43
-
44
- begin
45
- Dir.glob(File.join(data_dir, "test_run_*.json"))
46
- .sort_by { |f| File.mtime(f) }
47
- .reverse
48
- .first(limit)
49
- .map do |file_path|
50
- run_id = File.basename(file_path, '.json').gsub('test_run_', '')
51
- {
52
- id: run_id,
53
- file_path: file_path,
54
- created_at: File.mtime(file_path),
55
- size: File.size(file_path)
56
- }
57
- end
58
- rescue => e
59
- ErrorHandler.handle_error(e, {
60
- error_type: 'persistence',
61
- operation: 'list_test_runs'
62
- })
63
- []
64
- end
65
- end
66
-
67
- def delete_test_run(run_id)
68
- file_path = test_run_file_path(run_id)
69
- return false unless File.exist?(file_path)
70
-
71
- begin
72
- File.delete(file_path)
73
- Logger.info("Test run deleted", { run_id: run_id })
74
- true
75
- rescue => e
76
- ErrorHandler.handle_error(e, {
77
- error_type: 'persistence',
78
- operation: 'delete_test_run',
79
- run_id: run_id
80
- })
81
- false
82
- end
83
- end
84
-
85
- def cleanup_old_runs(days_to_keep = 30)
86
- ensure_data_directory
87
- cutoff_time = Time.now - (days_to_keep * 24 * 60 * 60)
88
- deleted_count = 0
89
-
90
- begin
91
- Dir.glob(File.join(data_directory, "test_run_*.json")).each do |file_path|
92
- if File.mtime(file_path) < cutoff_time
93
- run_id = File.basename(file_path, '.json').gsub('test_run_', '')
94
- if delete_test_run(run_id)
95
- deleted_count += 1
96
- end
97
- end
98
- end
99
-
100
- Logger.info("Cleanup completed", {
101
- deleted_runs: deleted_count,
102
- days_kept: days_to_keep
103
- })
104
-
105
- deleted_count
106
- rescue => e
107
- ErrorHandler.handle_error(e, {
108
- error_type: 'persistence',
109
- operation: 'cleanup_old_runs'
110
- })
111
- 0
112
- end
113
- end
114
-
115
- private
116
-
117
- def data_directory
118
- @data_directory ||= File.join(Dir.pwd, "tmp", "capydash_data")
119
- end
120
-
121
- def ensure_data_directory
122
- FileUtils.mkdir_p(data_directory) unless Dir.exist?(data_directory)
123
- end
124
-
125
- def test_run_file_path(run_id)
126
- File.join(data_directory, "test_run_#{run_id}.json")
127
- end
128
-
129
- def generate_run_id
130
- "#{Time.now.to_i}_#{SecureRandom.hex(4)}"
131
- end
132
- end
133
- end
134
- end