headless_browser_tool 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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +21 -0
  3. data/.rubocop.yml +56 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CLAUDE.md +298 -0
  7. data/LICENSE.md +7 -0
  8. data/README.md +522 -0
  9. data/Rakefile +12 -0
  10. data/config.ru +8 -0
  11. data/exe/hbt +7 -0
  12. data/lib/headless_browser_tool/browser.rb +374 -0
  13. data/lib/headless_browser_tool/browser_adapter.rb +320 -0
  14. data/lib/headless_browser_tool/cli.rb +34 -0
  15. data/lib/headless_browser_tool/directory_setup.rb +25 -0
  16. data/lib/headless_browser_tool/logger.rb +31 -0
  17. data/lib/headless_browser_tool/server.rb +150 -0
  18. data/lib/headless_browser_tool/session_manager.rb +199 -0
  19. data/lib/headless_browser_tool/session_middleware.rb +158 -0
  20. data/lib/headless_browser_tool/session_persistence.rb +146 -0
  21. data/lib/headless_browser_tool/stdio_server.rb +73 -0
  22. data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
  23. data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
  24. data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
  25. data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
  26. data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
  27. data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
  28. data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
  29. data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
  30. data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
  31. data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
  32. data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
  33. data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
  34. data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
  35. data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
  36. data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
  37. data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
  38. data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
  39. data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
  40. data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
  41. data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
  42. data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
  43. data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
  44. data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
  45. data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
  46. data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
  47. data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
  48. data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
  49. data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
  50. data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
  51. data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
  52. data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
  53. data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
  54. data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
  55. data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
  56. data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
  57. data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
  58. data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
  59. data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
  60. data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
  61. data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
  62. data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
  63. data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
  64. data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
  65. data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
  66. data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
  67. data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
  68. data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
  69. data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
  70. data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
  71. data/lib/headless_browser_tool/tools.rb +104 -0
  72. data/lib/headless_browser_tool/version.rb +5 -0
  73. data/lib/headless_browser_tool.rb +8 -0
  74. metadata +256 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "logger"
6
+
7
+ module HeadlessBrowserTool
8
+ module SessionPersistence # rubocop:disable Metrics/ModuleLength
9
+ BLANK_URLS = ["about:blank", "data:,"].freeze
10
+ SESSIONS_DIR = File.join(".hbt", "sessions").freeze
11
+
12
+ module_function
13
+
14
+ def save_session(session_id, capybara_session)
15
+ return unless session_id && capybara_session
16
+
17
+ FileUtils.mkdir_p(SESSIONS_DIR)
18
+ session_file = File.join(SESSIONS_DIR, "#{session_id}.json")
19
+
20
+ begin
21
+ state = {
22
+ session_id: session_id,
23
+ saved_at: Time.now.iso8601,
24
+ current_url: capybara_session.current_url,
25
+ cookies: extract_cookies(capybara_session),
26
+ local_storage: extract_storage(capybara_session, "localStorage"),
27
+ session_storage: extract_storage(capybara_session, "sessionStorage"),
28
+ window_size: extract_window_size(capybara_session)
29
+ }
30
+
31
+ File.write(session_file, JSON.pretty_generate(state))
32
+ HeadlessBrowserTool::Logger.log.info "Saved session to #{session_file}"
33
+ rescue StandardError => e
34
+ HeadlessBrowserTool::Logger.log.info "Error saving session: #{e.message}"
35
+ end
36
+ end
37
+
38
+ def restore_session(session_id, capybara_session)
39
+ session_file = File.join(SESSIONS_DIR, "#{session_id}.json")
40
+ return unless File.exist?(session_file)
41
+
42
+ begin
43
+ state = JSON.parse(File.read(session_file))
44
+ HeadlessBrowserTool::Logger.log.info "Restoring session from #{session_file}"
45
+
46
+ # Visit the URL first (needed to set cookies/storage for the domain)
47
+ capybara_session.visit(state["current_url"]) if state["current_url"] && !BLANK_URLS.include?(state["current_url"])
48
+
49
+ # Restore cookies
50
+ restore_cookies(capybara_session, state["cookies"]) if state["cookies"]
51
+
52
+ # Restore localStorage
53
+ restore_storage(capybara_session, "localStorage", state["local_storage"]) if state["local_storage"]
54
+
55
+ # Restore sessionStorage
56
+ restore_storage(capybara_session, "sessionStorage", state["session_storage"]) if state["session_storage"]
57
+
58
+ # Restore window size
59
+ if state["window_size"]
60
+ capybara_session.current_window.resize_to(
61
+ state["window_size"]["width"],
62
+ state["window_size"]["height"]
63
+ )
64
+ end
65
+
66
+ HeadlessBrowserTool::Logger.log.info "Session restored successfully"
67
+ true
68
+ rescue StandardError => e
69
+ HeadlessBrowserTool::Logger.log.info "Error restoring session: #{e.message}"
70
+ # Delete corrupted state file
71
+ begin
72
+ File.delete(session_file)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+ false
77
+ end
78
+ end
79
+
80
+ def session_exists?(session_id)
81
+ File.exist?(File.join(SESSIONS_DIR, "#{session_id}.json"))
82
+ end
83
+
84
+ def delete_session(session_id)
85
+ session_file = File.join(SESSIONS_DIR, "#{session_id}.json")
86
+ FileUtils.rm_f(session_file)
87
+ end
88
+
89
+ def extract_cookies(session)
90
+ session.driver.browser.manage.all_cookies
91
+ rescue StandardError => e
92
+ HeadlessBrowserTool::Logger.log.info "Error extracting cookies: #{e.message}"
93
+ []
94
+ end
95
+
96
+ def extract_storage(session, storage_type)
97
+ return {} if BLANK_URLS.include?(session.current_url)
98
+
99
+ session.evaluate_script(<<~JS)
100
+ (() => {
101
+ const items = {};
102
+ for (let i = 0; i < #{storage_type}.length; i++) {
103
+ const key = #{storage_type}.key(i);
104
+ items[key] = #{storage_type}.getItem(key);
105
+ }
106
+ return items;
107
+ })()
108
+ JS
109
+ rescue StandardError => e
110
+ HeadlessBrowserTool::Logger.log.info "Error extracting #{storage_type}: #{e.message}"
111
+ {}
112
+ end
113
+
114
+ def extract_window_size(session)
115
+ size = session.current_window.size
116
+ { "width" => size[0], "height" => size[1] }
117
+ rescue StandardError => e
118
+ HeadlessBrowserTool::Logger.log.info "Error extracting window size: #{e.message}"
119
+ nil
120
+ end
121
+
122
+ def restore_cookies(session, cookies)
123
+ return if cookies.nil? || cookies.empty?
124
+
125
+ cookies.each do |cookie|
126
+ cookie_hash = cookie.transform_keys(&:to_sym)
127
+ # Remove browser-specific fields that can't be set
128
+ cookie_hash.delete(:same_site)
129
+ cookie_hash.delete(:http_only) unless cookie_hash[:httpOnly]
130
+ session.driver.browser.manage.add_cookie(cookie_hash)
131
+ end
132
+ rescue StandardError => e
133
+ HeadlessBrowserTool::Logger.log.info "Error restoring cookies: #{e.message}"
134
+ end
135
+
136
+ def restore_storage(session, storage_type, storage_data)
137
+ return if storage_data.nil? || storage_data.empty? || BLANK_URLS.include?(session.current_url)
138
+
139
+ storage_data.each do |key, value|
140
+ session.execute_script("#{storage_type}.setItem(#{key.to_json}, #{value.to_json})")
141
+ end
142
+ rescue StandardError => e
143
+ HeadlessBrowserTool::Logger.log.info "Error restoring #{storage_type}: #{e.message}"
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+ require "fileutils"
5
+ require "json"
6
+ require_relative "browser"
7
+ require_relative "tools"
8
+ require_relative "version"
9
+ require_relative "logger"
10
+ require_relative "server"
11
+ require_relative "session_persistence"
12
+ require_relative "directory_setup"
13
+
14
+ module HeadlessBrowserTool
15
+ class StdioServer
16
+ def self.start(options = {})
17
+ # Setup directory structure with logs FIRST
18
+ DirectorySetup.setup_directories(include_logs: true)
19
+
20
+ # Initialize logger for stdio mode AFTER directories exist
21
+ HeadlessBrowserTool::Logger.initialize_logger(mode: :stdio)
22
+
23
+ # Check for session ID from environment
24
+ session_id = ENV.fetch("HBT_SESSION_ID", nil)
25
+
26
+ # Create MCP server instance
27
+ server = FastMcp::Server.new(
28
+ name: "headless-browser-tool",
29
+ version: HeadlessBrowserTool::VERSION
30
+ )
31
+
32
+ # Create single browser instance
33
+ # In stdio mode, always single session and headless by default
34
+ browser = Browser.new(headless: options.fetch(:headless, true))
35
+
36
+ # Store browser instance for tools to access
37
+ Server.browser_instance = browser
38
+ Server.single_session_mode = true
39
+
40
+ # Restore session if session ID provided
41
+ if session_id
42
+ HeadlessBrowserTool::Logger.log.info "Session ID provided: #{session_id}"
43
+ SessionPersistence.restore_session(session_id, browser.session)
44
+ end
45
+
46
+ # Register all browser tools
47
+ HeadlessBrowserTool::Tools::ALL_TOOLS.each do |tool_class|
48
+ server.register_tool(tool_class)
49
+ end
50
+
51
+ # Log startup info to log file since stdout is used for MCP protocol
52
+ HeadlessBrowserTool::Logger.log.info "Starting HeadlessBrowserTool MCP server in stdio mode..."
53
+ HeadlessBrowserTool::Logger.log.info "Headless: #{options.fetch(:headless, true)}"
54
+
55
+ # Store references for shutdown
56
+ @browser = browser
57
+ @session_id = session_id
58
+
59
+ # Register shutdown hook
60
+ at_exit do
61
+ SessionPersistence.save_session(@session_id, @browser.session) if @session_id
62
+ end
63
+
64
+ # Start the server in stdio mode
65
+ server.start
66
+ rescue Interrupt
67
+ HeadlessBrowserTool::Logger.log.info "Shutting down..."
68
+ SessionPersistence.save_session(session_id, browser.session) if session_id
69
+ ensure
70
+ SessionPersistence.save_session(session_id, browser.session) if session_id
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logger"
4
+
5
+ module HeadlessBrowserTool
6
+ class StrictSessionMiddleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ # Log request headers if enabled
13
+ log_request_headers(env) if HeadlessBrowserTool::Server.show_headers
14
+
15
+ # Extract session ID from X-Session-ID header only
16
+ session_id = env["HTTP_X_SESSION_ID"]
17
+
18
+ # For MCP requests, require session ID
19
+ if mcp_request?(env) && (session_id.nil? || session_id.empty?)
20
+ return [
21
+ 400,
22
+ { "Content-Type" => "application/json" },
23
+ [{ error: "X-Session-ID header is required for multi-session mode" }.to_json]
24
+ ]
25
+ end
26
+
27
+ # Sanitize session ID
28
+ if session_id
29
+ session_id = sanitize_session_id(session_id)
30
+ if session_id.nil?
31
+ return [
32
+ 400,
33
+ { "Content-Type" => "application/json" },
34
+ [{ error: "Invalid X-Session-ID format" }.to_json]
35
+ ]
36
+ end
37
+ end
38
+
39
+ # Store in environment for downstream use
40
+ env["hbt.session_id"] = session_id || "default"
41
+
42
+ # Call the app
43
+ status, headers, response = @app.call(env)
44
+
45
+ # Add session ID to response headers
46
+ headers["X-Session-ID"] = session_id if session_id
47
+
48
+ [status, headers, response]
49
+ end
50
+
51
+ private
52
+
53
+ def sanitize_session_id(value)
54
+ return nil if value.nil? || value.empty?
55
+
56
+ # Ensure it's alphanumeric with underscores/hyphens only
57
+ sanitized = value.gsub(/[^a-zA-Z0-9_\-]/, "")
58
+
59
+ # Limit length
60
+ sanitized = sanitized[0..64]
61
+
62
+ # Return nil if invalid
63
+ return nil if sanitized.empty?
64
+
65
+ sanitized
66
+ end
67
+
68
+ def mcp_request?(env)
69
+ env["PATH_INFO"]&.start_with?("/mcp")
70
+ end
71
+
72
+ def log_request_headers(env)
73
+ HeadlessBrowserTool::Logger.log.info "\n=== REQUEST HEADERS ==="
74
+ HeadlessBrowserTool::Logger.log.info "Method: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}"
75
+ HeadlessBrowserTool::Logger.log.info "Remote IP: #{env["REMOTE_ADDR"]}"
76
+
77
+ # Log all HTTP headers
78
+ env.select { |k, _v| k.start_with?("HTTP_") }.each do |key, value|
79
+ header_name = key.sub("HTTP_", "").split("_").map(&:capitalize).join("-")
80
+ HeadlessBrowserTool::Logger.log.info "#{header_name}: #{value}"
81
+ end
82
+
83
+ # Log specific session-related info
84
+ HeadlessBrowserTool::Logger.log.info "Session ID: #{env["HTTP_X_SESSION_ID"] || "NOT PROVIDED"}"
85
+ HeadlessBrowserTool::Logger.log.info "=====================\n"
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module HeadlessBrowserTool
6
+ module Tools
7
+ class AttachFileTool < BaseTool
8
+ tool_name "attach_file"
9
+ description "Upload file to file input field"
10
+
11
+ arguments do
12
+ required(:file_field_selector).filled(:string).description("CSS selector of the file input field")
13
+ required(:file_path).filled(:string).description("Path to the file to upload")
14
+ end
15
+
16
+ def execute(file_field_selector:, file_path:)
17
+ file_field = browser.find(file_field_selector)
18
+
19
+ # Get file info
20
+ file_name = File.basename(file_path)
21
+ file_size = File.size(file_path) if File.exist?(file_path)
22
+
23
+ browser.attach_file(file_field_selector, file_path)
24
+
25
+ {
26
+ field_selector: file_field_selector,
27
+ file_path: file_path,
28
+ file_name: file_name,
29
+ file_size: file_size,
30
+ field: {
31
+ id: file_field[:id],
32
+ name: file_field[:name],
33
+ accept: file_field[:accept]
34
+ }.compact,
35
+ status: "attached"
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module HeadlessBrowserTool
6
+ module Tools
7
+ class AutoNarrateTool < BaseTool
8
+ tool_name "auto_narrate"
9
+ description "Enable automatic narration of what's happening on the page"
10
+
11
+ arguments do
12
+ optional(:enabled).filled(:bool).description("Enable or disable auto-narration")
13
+ end
14
+
15
+ def execute(enabled: true)
16
+ if enabled
17
+ inject_narration_script
18
+ "Auto-narration enabled. Browser will now describe significant events."
19
+ else
20
+ disable_narration
21
+ "Auto-narration disabled."
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def inject_narration_script
28
+ script = <<~JS
29
+ (() => {
30
+ // Store narration state
31
+ window.__aiNarration = window.__aiNarration || [];
32
+
33
+ const narrate = (message) => {
34
+ const timestamp = new Date().toISOString();
35
+ window.__aiNarration.push({ timestamp, message });
36
+ console.log(`[AI Narration] ${message}`);
37
+
38
+ // Keep only last 50 events
39
+ if (window.__aiNarration.length > 50) {
40
+ window.__aiNarration.shift();
41
+ }
42
+ };
43
+
44
+ // Monitor page changes
45
+ const observer = new MutationObserver((mutations) => {
46
+ const significantChanges = [];
47
+
48
+ mutations.forEach((mutation) => {
49
+ // New nodes added
50
+ if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
51
+ Array.from(mutation.addedNodes).forEach((node) => {
52
+ if (node.nodeType === 1) { // Element node
53
+ if (node.tagName === 'DIV' && node.classList.contains('modal')) {
54
+ significantChanges.push('A modal dialog appeared');
55
+ } else if (node.tagName === 'FORM') {
56
+ significantChanges.push('A new form appeared on the page');
57
+ } else if (node.classList && node.classList.contains('error')) {
58
+ significantChanges.push(`Error message: "${node.textContent.trim()}"`);
59
+ } else if (node.classList && node.classList.contains('success')) {
60
+ significantChanges.push(`Success message: "${node.textContent.trim()}"`);
61
+ }
62
+ }
63
+ });
64
+ }
65
+
66
+ // Attributes changed
67
+ if (mutation.type === 'attributes') {
68
+ if (mutation.attributeName === 'disabled') {
69
+ const element = mutation.target;
70
+ if (element.tagName === 'BUTTON' || element.tagName === 'INPUT') {
71
+ const action = element.disabled ? 'disabled' : 'enabled';
72
+ significantChanges.push(`${element.tagName} "${element.textContent || element.value}" was ${action}`);
73
+ }
74
+ }
75
+ }
76
+ });
77
+
78
+ // Narrate significant changes
79
+ significantChanges.forEach(narrate);
80
+ });
81
+
82
+ // Start observing
83
+ observer.observe(document.body, {
84
+ childList: true,
85
+ attributes: true,
86
+ subtree: true,
87
+ attributeFilter: ['disabled', 'hidden', 'style']
88
+ });
89
+
90
+ // Monitor form submissions
91
+ document.addEventListener('submit', (e) => {
92
+ const form = e.target;
93
+ const formName = form.id || form.name || 'unnamed form';
94
+ narrate(`Form "${formName}" is being submitted`);
95
+ }, true);
96
+
97
+ // Monitor clicks
98
+ document.addEventListener('click', (e) => {
99
+ const target = e.target;
100
+ if (target.tagName === 'BUTTON' || target.tagName === 'A') {
101
+ const text = target.textContent.trim();
102
+ if (text) {
103
+ narrate(`Clicked on "${text}"`);
104
+ }
105
+ }
106
+ }, true);
107
+
108
+ // Monitor page visibility
109
+ document.addEventListener('visibilitychange', () => {
110
+ narrate(document.hidden ? 'Page became hidden' : 'Page became visible');
111
+ });
112
+
113
+ // Monitor AJAX requests
114
+ const originalFetch = window.fetch;
115
+ window.fetch = function(...args) {
116
+ const url = args[0];
117
+ narrate(`Making request to: ${url}`);
118
+ return originalFetch.apply(this, args)
119
+ .then(response => {
120
+ narrate(`Request completed: ${response.status} ${response.statusText}`);
121
+ return response;
122
+ })
123
+ .catch(error => {
124
+ narrate(`Request failed: ${error.message}`);
125
+ throw error;
126
+ });
127
+ };
128
+
129
+ // Initial narration
130
+ narrate(`Monitoring page: "${document.title}"`);
131
+
132
+ // Function to get narration history
133
+ window.getAINarration = () => window.__aiNarration;
134
+ })();
135
+ JS
136
+
137
+ browser.execute_script(script)
138
+ end
139
+
140
+ def disable_narration
141
+ script = <<~JS
142
+ (() => {
143
+ // Remove observers and restore original functions
144
+ if (window.__aiNarrationObserver) {
145
+ window.__aiNarrationObserver.disconnect();
146
+ }
147
+ window.__aiNarration = [];
148
+ })();
149
+ JS
150
+
151
+ browser.execute_script(script)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+ require_relative "../logger"
5
+
6
+ module HeadlessBrowserTool
7
+ module Tools
8
+ class BaseTool < FastMcp::Tool
9
+ protected
10
+
11
+ def browser
12
+ if HeadlessBrowserTool::Server.single_session_mode
13
+ # Use the single shared browser instance
14
+ HeadlessBrowserTool::Server.browser_instance
15
+ else
16
+ # Get session-specific browser
17
+ session_id = Thread.current[:hbt_session_id]
18
+ raise "No session ID provided. X-Session-ID header is required in multi-session mode" if session_id.nil? || session_id == "default"
19
+
20
+ capybara_session = HeadlessBrowserTool::Server.session_manager.get_or_create_session(session_id)
21
+ HeadlessBrowserTool::BrowserAdapter.new(capybara_session, session_id)
22
+ end
23
+ end
24
+
25
+ def execute(*args, **kwargs)
26
+ raise NotImplementedError, "Subclasses must implement the execute method"
27
+ end
28
+
29
+ def call(*args, **kwargs)
30
+ result = execute(*args, **kwargs)
31
+ HeadlessBrowserTool::Logger.log.info "CALL: #{self.class.name} #{args.inspect} #{kwargs.inspect} -> #{result.inspect}"
32
+ result
33
+ rescue StandardError => e
34
+ HeadlessBrowserTool::Logger.log.error "ERROR: #{self.class.name} #{args.inspect} #{kwargs.inspect} -> #{e.message}"
35
+ e.message
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module HeadlessBrowserTool
6
+ module Tools
7
+ class CheckTool < BaseTool
8
+ tool_name "check"
9
+ description "Check a checkbox by selector"
10
+
11
+ arguments do
12
+ required(:checkbox_selector).filled(:string).description("CSS selector of the checkbox to check")
13
+ end
14
+
15
+ def execute(checkbox_selector:)
16
+ element = browser.find(checkbox_selector)
17
+ was_checked = element.checked?
18
+ browser.check(checkbox_selector)
19
+
20
+ {
21
+ selector: checkbox_selector,
22
+ was_checked: was_checked,
23
+ is_checked: true,
24
+ element: {
25
+ id: element[:id],
26
+ name: element[:name],
27
+ value: element[:value],
28
+ type: element[:type]
29
+ }.compact,
30
+ status: "checked"
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module HeadlessBrowserTool
6
+ module Tools
7
+ class ChooseTool < BaseTool
8
+ tool_name "choose"
9
+ description "Select a radio button by selector"
10
+
11
+ arguments do
12
+ required(:radio_button_selector).filled(:string).description("CSS selector of the radio button to select")
13
+ end
14
+
15
+ def execute(radio_button_selector:)
16
+ radio = browser.find(radio_button_selector)
17
+
18
+ # Get radio group info
19
+ name = radio[:name]
20
+ radio_group = browser.all("input[type='radio'][name='#{name}']") if name
21
+
22
+ radio_info = {
23
+ id: radio[:id],
24
+ name: name,
25
+ value: radio[:value],
26
+ was_checked: radio.checked?
27
+ }.compact
28
+
29
+ browser.choose(radio_button_selector)
30
+
31
+ # Get all radio buttons in the same group
32
+ group_info = radio_group&.map&.with_index do |r, index|
33
+ selector = if r[:id] && !r[:id].empty?
34
+ "##{r[:id]}"
35
+ else
36
+ "input[type='radio'][name='#{name}']:nth-of-type(#{index + 1})"
37
+ end
38
+
39
+ {
40
+ selector: selector,
41
+ value: r[:value],
42
+ checked: r.checked?,
43
+ id: r[:id]
44
+ }.compact
45
+ end
46
+
47
+ {
48
+ selector: radio_button_selector,
49
+ radio: radio_info,
50
+ group: group_info,
51
+ status: "selected"
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module HeadlessBrowserTool
6
+ module Tools
7
+ class ClickButtonTool < BaseTool
8
+ tool_name "click_button"
9
+ description "Click button by text or selector"
10
+
11
+ arguments do
12
+ required(:button_text_or_selector).filled(:string).description("Button text or CSS selector")
13
+ end
14
+
15
+ def execute(button_text_or_selector:)
16
+ url_before = browser.current_url
17
+ browser.title
18
+
19
+ # Find the button element first
20
+ button = begin
21
+ browser.find_button(button_text_or_selector)
22
+ rescue Capybara::ElementNotFound
23
+ browser.find(button_text_or_selector)
24
+ end
25
+
26
+ button_info = {
27
+ text: button.text.strip,
28
+ value: button[:value],
29
+ type: button[:type],
30
+ disabled: button.disabled?
31
+ }.compact
32
+
33
+ browser.click_button(button_text_or_selector)
34
+
35
+ {
36
+ button: button_text_or_selector,
37
+ element: button_info,
38
+ navigation: {
39
+ navigated: browser.current_url != url_before,
40
+ from: url_before,
41
+ to: browser.current_url,
42
+ title: browser.title
43
+ },
44
+ status: "clicked"
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end