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.
- checksums.yaml +7 -0
- data/.claude/settings.json +21 -0
- data/.rubocop.yml +56 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +298 -0
- data/LICENSE.md +7 -0
- data/README.md +522 -0
- data/Rakefile +12 -0
- data/config.ru +8 -0
- data/exe/hbt +7 -0
- data/lib/headless_browser_tool/browser.rb +374 -0
- data/lib/headless_browser_tool/browser_adapter.rb +320 -0
- data/lib/headless_browser_tool/cli.rb +34 -0
- data/lib/headless_browser_tool/directory_setup.rb +25 -0
- data/lib/headless_browser_tool/logger.rb +31 -0
- data/lib/headless_browser_tool/server.rb +150 -0
- data/lib/headless_browser_tool/session_manager.rb +199 -0
- data/lib/headless_browser_tool/session_middleware.rb +158 -0
- data/lib/headless_browser_tool/session_persistence.rb +146 -0
- data/lib/headless_browser_tool/stdio_server.rb +73 -0
- data/lib/headless_browser_tool/strict_session_middleware.rb +88 -0
- data/lib/headless_browser_tool/tools/attach_file_tool.rb +40 -0
- data/lib/headless_browser_tool/tools/auto_narrate_tool.rb +155 -0
- data/lib/headless_browser_tool/tools/base_tool.rb +39 -0
- data/lib/headless_browser_tool/tools/check_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/choose_tool.rb +56 -0
- data/lib/headless_browser_tool/tools/click_button_tool.rb +49 -0
- data/lib/headless_browser_tool/tools/click_link_tool.rb +48 -0
- data/lib/headless_browser_tool/tools/click_tool.rb +45 -0
- data/lib/headless_browser_tool/tools/close_window_tool.rb +31 -0
- data/lib/headless_browser_tool/tools/double_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/drag_tool.rb +46 -0
- data/lib/headless_browser_tool/tools/evaluate_script_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/execute_script_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/fill_in_tool.rb +66 -0
- data/lib/headless_browser_tool/tools/find_all_tool.rb +42 -0
- data/lib/headless_browser_tool/tools/find_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/find_elements_containing_text_tool.rb +259 -0
- data/lib/headless_browser_tool/tools/get_attribute_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/get_current_path_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_current_url_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_narration_history_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/get_page_context_tool.rb +188 -0
- data/lib/headless_browser_tool/tools/get_page_source_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_page_title_tool.rb +16 -0
- data/lib/headless_browser_tool/tools/get_session_info_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/get_text_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_value_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/get_window_handles_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_back_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/go_forward_tool.rb +29 -0
- data/lib/headless_browser_tool/tools/has_element_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/has_text_tool.rb +21 -0
- data/lib/headless_browser_tool/tools/hover_tool.rb +38 -0
- data/lib/headless_browser_tool/tools/is_visible_tool.rb +20 -0
- data/lib/headless_browser_tool/tools/maximize_window_tool.rb +34 -0
- data/lib/headless_browser_tool/tools/open_new_window_tool.rb +25 -0
- data/lib/headless_browser_tool/tools/refresh_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/resize_window_tool.rb +43 -0
- data/lib/headless_browser_tool/tools/right_click_tool.rb +37 -0
- data/lib/headless_browser_tool/tools/save_page_tool.rb +32 -0
- data/lib/headless_browser_tool/tools/screenshot_tool.rb +199 -0
- data/lib/headless_browser_tool/tools/search_page_tool.rb +224 -0
- data/lib/headless_browser_tool/tools/search_source_tool.rb +148 -0
- data/lib/headless_browser_tool/tools/select_tool.rb +44 -0
- data/lib/headless_browser_tool/tools/switch_to_window_tool.rb +30 -0
- data/lib/headless_browser_tool/tools/uncheck_tool.rb +35 -0
- data/lib/headless_browser_tool/tools/visit_tool.rb +27 -0
- data/lib/headless_browser_tool/tools/visual_diff_tool.rb +177 -0
- data/lib/headless_browser_tool/tools.rb +104 -0
- data/lib/headless_browser_tool/version.rb +5 -0
- data/lib/headless_browser_tool.rb +8 -0
- 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
|