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,374 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "capybara"
|
4
|
+
require "capybara/dsl"
|
5
|
+
require "selenium-webdriver"
|
6
|
+
|
7
|
+
module HeadlessBrowserTool
|
8
|
+
class Browser
|
9
|
+
include Capybara::DSL
|
10
|
+
|
11
|
+
attr_reader :session
|
12
|
+
attr_accessor :previous_state
|
13
|
+
|
14
|
+
def initialize(headless: true)
|
15
|
+
configure_capybara(headless)
|
16
|
+
@session = Capybara.current_session
|
17
|
+
@previous_state = {}
|
18
|
+
end
|
19
|
+
|
20
|
+
def active?
|
21
|
+
!@session.driver.browser.nil?
|
22
|
+
rescue StandardError
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
# Navigation tools
|
27
|
+
def visit(url)
|
28
|
+
@session.visit(url)
|
29
|
+
{ message: "Navigated to #{url}" }
|
30
|
+
end
|
31
|
+
|
32
|
+
def refresh
|
33
|
+
@session.refresh
|
34
|
+
{ message: "Page refreshed" }
|
35
|
+
end
|
36
|
+
|
37
|
+
def go_back
|
38
|
+
@session.go_back
|
39
|
+
{ message: "Navigated back" }
|
40
|
+
end
|
41
|
+
|
42
|
+
def go_forward
|
43
|
+
@session.go_forward
|
44
|
+
{ message: "Navigated forward" }
|
45
|
+
end
|
46
|
+
|
47
|
+
# Interaction tools
|
48
|
+
def click(selector)
|
49
|
+
element = @session.find(selector)
|
50
|
+
element.click
|
51
|
+
{ message: "Clicked element: #{selector}" }
|
52
|
+
end
|
53
|
+
|
54
|
+
def right_click(selector)
|
55
|
+
element = @session.find(selector)
|
56
|
+
element.right_click
|
57
|
+
{ message: "Right-clicked element: #{selector}" }
|
58
|
+
end
|
59
|
+
|
60
|
+
def double_click(selector)
|
61
|
+
element = @session.find(selector)
|
62
|
+
element.double_click
|
63
|
+
{ message: "Double-clicked element: #{selector}" }
|
64
|
+
end
|
65
|
+
|
66
|
+
def hover(selector)
|
67
|
+
element = @session.find(selector)
|
68
|
+
element.hover
|
69
|
+
{ message: "Hovered over element: #{selector}" }
|
70
|
+
end
|
71
|
+
|
72
|
+
def drag(source_selector, target_selector)
|
73
|
+
source = @session.find(source_selector)
|
74
|
+
target = @session.find(target_selector)
|
75
|
+
source.drag_to(target)
|
76
|
+
{ message: "Dragged from #{source_selector} to #{target_selector}" }
|
77
|
+
end
|
78
|
+
|
79
|
+
# Element tools
|
80
|
+
def find_element(selector)
|
81
|
+
element = @session.find(selector)
|
82
|
+
{
|
83
|
+
tag_name: element.tag_name,
|
84
|
+
text: element.text,
|
85
|
+
visible: element.visible?,
|
86
|
+
location: { x: element.native.location.x, y: element.native.location.y }
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
def find_all(selector)
|
91
|
+
elements = @session.all(selector)
|
92
|
+
elements.map do |element|
|
93
|
+
{
|
94
|
+
tag_name: element.tag_name,
|
95
|
+
text: element.text,
|
96
|
+
visible: element.visible?,
|
97
|
+
attributes: extract_attributes(element),
|
98
|
+
value: element.value
|
99
|
+
}
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def get_text(selector)
|
104
|
+
element = @session.find(selector)
|
105
|
+
element.text
|
106
|
+
end
|
107
|
+
|
108
|
+
def get_attribute(selector, attribute_name)
|
109
|
+
element = @session.find(selector)
|
110
|
+
element[attribute_name]
|
111
|
+
end
|
112
|
+
|
113
|
+
def get_value(selector)
|
114
|
+
element = @session.find(selector)
|
115
|
+
element.value
|
116
|
+
end
|
117
|
+
|
118
|
+
def is_visible?(selector)
|
119
|
+
element = @session.find(selector)
|
120
|
+
element.visible?
|
121
|
+
rescue Capybara::ElementNotFound
|
122
|
+
false
|
123
|
+
end
|
124
|
+
|
125
|
+
def has_element?(selector, wait_seconds = nil)
|
126
|
+
if wait_seconds
|
127
|
+
@session.has_selector?(selector, wait: wait_seconds)
|
128
|
+
else
|
129
|
+
@session.has_selector?(selector)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def has_text?(text, wait_seconds = nil)
|
134
|
+
if wait_seconds
|
135
|
+
@session.has_text?(text, wait: wait_seconds)
|
136
|
+
else
|
137
|
+
@session.has_text?(text)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Form tools
|
142
|
+
def fill_in(field, value)
|
143
|
+
@session.fill_in(field, with: value)
|
144
|
+
{ message: "Filled '#{field}' with '#{value}'" }
|
145
|
+
end
|
146
|
+
|
147
|
+
def select(value, dropdown_selector)
|
148
|
+
@session.select(value, from: dropdown_selector)
|
149
|
+
{ message: "Selected '#{value}' from '#{dropdown_selector}'" }
|
150
|
+
end
|
151
|
+
|
152
|
+
def check(checkbox_selector)
|
153
|
+
@session.check(checkbox_selector)
|
154
|
+
{ message: "Checked '#{checkbox_selector}'" }
|
155
|
+
end
|
156
|
+
|
157
|
+
def uncheck(checkbox_selector)
|
158
|
+
@session.uncheck(checkbox_selector)
|
159
|
+
{ message: "Unchecked '#{checkbox_selector}'" }
|
160
|
+
end
|
161
|
+
|
162
|
+
def choose(radio_button_selector)
|
163
|
+
@session.choose(radio_button_selector)
|
164
|
+
{ message: "Chose '#{radio_button_selector}'" }
|
165
|
+
end
|
166
|
+
|
167
|
+
def attach_file(file_field_selector, file_path)
|
168
|
+
@session.attach_file(file_field_selector, file_path)
|
169
|
+
{ message: "Attached '#{file_path}' to '#{file_field_selector}'" }
|
170
|
+
end
|
171
|
+
|
172
|
+
def click_button(button_text_or_selector)
|
173
|
+
@session.click_button(button_text_or_selector)
|
174
|
+
{ message: "Clicked button: #{button_text_or_selector}" }
|
175
|
+
end
|
176
|
+
|
177
|
+
def click_link(link_text_or_selector)
|
178
|
+
@session.click_link(link_text_or_selector)
|
179
|
+
{ message: "Clicked link: #{link_text_or_selector}" }
|
180
|
+
end
|
181
|
+
|
182
|
+
# Info tools
|
183
|
+
def get_current_url
|
184
|
+
@session.current_url
|
185
|
+
end
|
186
|
+
|
187
|
+
def get_current_path
|
188
|
+
@session.current_path
|
189
|
+
end
|
190
|
+
|
191
|
+
def get_page_title
|
192
|
+
@session.title
|
193
|
+
end
|
194
|
+
|
195
|
+
def get_page_source
|
196
|
+
@session.html
|
197
|
+
end
|
198
|
+
|
199
|
+
# JavaScript tools
|
200
|
+
def execute_script(javascript_code)
|
201
|
+
@session.execute_script(javascript_code)
|
202
|
+
{ message: "Executed JavaScript" }
|
203
|
+
end
|
204
|
+
|
205
|
+
def evaluate_script(javascript_code)
|
206
|
+
@session.evaluate_script(javascript_code)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Utility tools
|
210
|
+
def save_screenshot(file_path)
|
211
|
+
@session.save_screenshot(file_path)
|
212
|
+
{ message: "Screenshot saved to #{file_path}" }
|
213
|
+
end
|
214
|
+
|
215
|
+
def save_page(file_path)
|
216
|
+
@session.save_page(file_path)
|
217
|
+
{ message: "Page saved to #{file_path}" }
|
218
|
+
end
|
219
|
+
|
220
|
+
# Window tools
|
221
|
+
def switch_to_window(window_handle)
|
222
|
+
@session.switch_to_window(window_handle)
|
223
|
+
current_window = @session.current_window
|
224
|
+
|
225
|
+
{
|
226
|
+
status: "success",
|
227
|
+
window_handle: current_window.handle,
|
228
|
+
current_url: @session.current_url,
|
229
|
+
title: @session.title,
|
230
|
+
position: get_window_position,
|
231
|
+
size: get_window_size,
|
232
|
+
is_current: true,
|
233
|
+
total_windows: @session.windows.length
|
234
|
+
}
|
235
|
+
end
|
236
|
+
|
237
|
+
def open_new_window
|
238
|
+
initial_windows_count = @session.windows.length
|
239
|
+
window = @session.open_new_window
|
240
|
+
|
241
|
+
# Switch to the new window to get its info
|
242
|
+
@session.switch_to_window(window)
|
243
|
+
|
244
|
+
{
|
245
|
+
status: "success",
|
246
|
+
window_handle: window.handle,
|
247
|
+
current_url: @session.current_url,
|
248
|
+
title: @session.title,
|
249
|
+
position: get_window_position,
|
250
|
+
size: get_window_size,
|
251
|
+
is_current: true,
|
252
|
+
total_windows: @session.windows.length,
|
253
|
+
previous_windows_count: initial_windows_count
|
254
|
+
}
|
255
|
+
end
|
256
|
+
|
257
|
+
def close_window(window_handle)
|
258
|
+
initial_windows_count = @session.windows.length
|
259
|
+
current_handle = @session.current_window.handle
|
260
|
+
window = @session.windows.find { |w| w.handle == window_handle }
|
261
|
+
|
262
|
+
if window.nil?
|
263
|
+
return {
|
264
|
+
status: "error",
|
265
|
+
error: "Window not found",
|
266
|
+
window_handle: window_handle
|
267
|
+
}
|
268
|
+
end
|
269
|
+
|
270
|
+
# If closing the current window, switch to another first
|
271
|
+
if window_handle == current_handle && @session.windows.length > 1
|
272
|
+
other_window = @session.windows.find { |w| w.handle != window_handle }
|
273
|
+
@session.switch_to_window(other_window) if other_window
|
274
|
+
end
|
275
|
+
|
276
|
+
window.close
|
277
|
+
|
278
|
+
{
|
279
|
+
status: "success",
|
280
|
+
closed_window_handle: window_handle,
|
281
|
+
remaining_windows: @session.windows.length,
|
282
|
+
initial_windows_count: initial_windows_count,
|
283
|
+
current_window_handle: @session.windows.any? ? @session.current_window.handle : nil
|
284
|
+
}
|
285
|
+
end
|
286
|
+
|
287
|
+
def get_window_handles
|
288
|
+
@session.windows.map(&:handle)
|
289
|
+
end
|
290
|
+
|
291
|
+
def maximize_window
|
292
|
+
current_window = @session.current_window
|
293
|
+
size_before = get_window_size
|
294
|
+
|
295
|
+
current_window.maximize
|
296
|
+
|
297
|
+
size_after = get_window_size
|
298
|
+
|
299
|
+
{
|
300
|
+
status: "success",
|
301
|
+
window_handle: current_window.handle,
|
302
|
+
size_before: size_before,
|
303
|
+
size_after: size_after,
|
304
|
+
current_url: @session.current_url,
|
305
|
+
title: @session.title
|
306
|
+
}
|
307
|
+
end
|
308
|
+
|
309
|
+
def resize_window(width, height)
|
310
|
+
current_window = @session.current_window
|
311
|
+
size_before = get_window_size
|
312
|
+
|
313
|
+
current_window.resize_to(width, height)
|
314
|
+
|
315
|
+
size_after = get_window_size
|
316
|
+
|
317
|
+
{
|
318
|
+
status: "success",
|
319
|
+
window_handle: current_window.handle,
|
320
|
+
size_before: size_before,
|
321
|
+
size_after: size_after,
|
322
|
+
requested_size: { width: width, height: height },
|
323
|
+
actual_size: size_after,
|
324
|
+
current_url: @session.current_url,
|
325
|
+
title: @session.title
|
326
|
+
}
|
327
|
+
end
|
328
|
+
|
329
|
+
private
|
330
|
+
|
331
|
+
def extract_attributes(element)
|
332
|
+
# Get common attributes that are often useful
|
333
|
+
attrs = {}
|
334
|
+
%w[id class href src alt title name type value placeholder data-testid role aria-label].each do |attr|
|
335
|
+
value = element[attr]
|
336
|
+
attrs[attr] = value if value && !value.empty?
|
337
|
+
end
|
338
|
+
attrs
|
339
|
+
end
|
340
|
+
|
341
|
+
def get_window_size
|
342
|
+
size = @session.current_window.size
|
343
|
+
{ width: size[0], height: size[1] }
|
344
|
+
rescue StandardError
|
345
|
+
{ width: nil, height: nil }
|
346
|
+
end
|
347
|
+
|
348
|
+
def get_window_position
|
349
|
+
# Try to get window position, but this might not be supported by all drivers
|
350
|
+
|
351
|
+
browser = @session.driver.browser
|
352
|
+
position = browser.manage.window.position
|
353
|
+
{ x: position.x, y: position.y }
|
354
|
+
rescue StandardError
|
355
|
+
{ x: nil, y: nil }
|
356
|
+
end
|
357
|
+
|
358
|
+
def configure_capybara(headless)
|
359
|
+
Capybara.register_driver :selenium_chrome do |app|
|
360
|
+
options = Selenium::WebDriver::Chrome::Options.new
|
361
|
+
options.add_argument("--headless") if headless
|
362
|
+
options.add_argument("--no-sandbox")
|
363
|
+
options.add_argument("--disable-dev-shm-usage")
|
364
|
+
options.add_argument("--disable-gpu") if headless
|
365
|
+
|
366
|
+
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
|
367
|
+
end
|
368
|
+
|
369
|
+
Capybara.default_driver = :selenium_chrome
|
370
|
+
Capybara.javascript_driver = :selenium_chrome
|
371
|
+
Capybara.default_max_wait_time = 10
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
@@ -0,0 +1,320 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HeadlessBrowserTool
|
4
|
+
# Adapter to make Capybara::Session compatible with our Browser interface
|
5
|
+
class BrowserAdapter
|
6
|
+
attr_reader :session, :session_id
|
7
|
+
attr_accessor :previous_state
|
8
|
+
|
9
|
+
def initialize(capybara_session, session_id)
|
10
|
+
@session = capybara_session
|
11
|
+
@session_id = session_id
|
12
|
+
@previous_state = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
# Navigation methods
|
16
|
+
def visit(url)
|
17
|
+
@session.visit(url)
|
18
|
+
end
|
19
|
+
|
20
|
+
def refresh
|
21
|
+
@session.refresh
|
22
|
+
end
|
23
|
+
|
24
|
+
def go_back
|
25
|
+
@session.go_back
|
26
|
+
end
|
27
|
+
|
28
|
+
def go_forward
|
29
|
+
@session.go_forward
|
30
|
+
end
|
31
|
+
|
32
|
+
# Finding elements
|
33
|
+
def find(selector, **)
|
34
|
+
@session.find(selector, **)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Alias for compatibility with our tools - just needs to not throw error
|
38
|
+
def find_element(selector)
|
39
|
+
@session.find(selector)
|
40
|
+
# Tool will return its own message
|
41
|
+
end
|
42
|
+
|
43
|
+
def all(selector, **)
|
44
|
+
@session.all(selector, **)
|
45
|
+
end
|
46
|
+
|
47
|
+
def has_selector?(selector, **)
|
48
|
+
@session.has_selector?(selector, **)
|
49
|
+
end
|
50
|
+
|
51
|
+
def has_no_selector?(selector, **)
|
52
|
+
@session.has_no_selector?(selector, **)
|
53
|
+
end
|
54
|
+
|
55
|
+
def has_text?(text, **)
|
56
|
+
@session.has_text?(text, **)
|
57
|
+
end
|
58
|
+
|
59
|
+
def has_no_text?(text, **)
|
60
|
+
@session.has_no_text?(text, **)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Interaction methods
|
64
|
+
def click(selector)
|
65
|
+
element = @session.find(selector)
|
66
|
+
element.click
|
67
|
+
end
|
68
|
+
|
69
|
+
def click_on(locator, **)
|
70
|
+
@session.click_on(locator, **)
|
71
|
+
end
|
72
|
+
|
73
|
+
def fill_in(field, value)
|
74
|
+
# Match original Browser class signature
|
75
|
+
@session.fill_in(field, with: value)
|
76
|
+
end
|
77
|
+
|
78
|
+
def choose(locator, **)
|
79
|
+
@session.choose(locator, **)
|
80
|
+
end
|
81
|
+
|
82
|
+
def check(locator, **)
|
83
|
+
@session.check(locator, **)
|
84
|
+
end
|
85
|
+
|
86
|
+
def uncheck(locator, **)
|
87
|
+
@session.uncheck(locator, **)
|
88
|
+
end
|
89
|
+
|
90
|
+
def select(value, dropdown_selector = nil, from: nil, **)
|
91
|
+
# Support both signatures: select(value, dropdown) and select(value, from: dropdown)
|
92
|
+
from ||= dropdown_selector
|
93
|
+
@session.select(value, from: from, **)
|
94
|
+
end
|
95
|
+
|
96
|
+
def attach_file(locator, path, **)
|
97
|
+
@session.attach_file(locator, path, **)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Additional methods to match Browser class
|
101
|
+
def get_text(selector)
|
102
|
+
@session.find(selector).text
|
103
|
+
end
|
104
|
+
|
105
|
+
def get_attribute(selector, attribute_name)
|
106
|
+
@session.find(selector)[attribute_name]
|
107
|
+
end
|
108
|
+
|
109
|
+
def get_value(selector)
|
110
|
+
@session.find(selector).value
|
111
|
+
end
|
112
|
+
|
113
|
+
def is_visible?(selector)
|
114
|
+
@session.find(selector).visible?
|
115
|
+
rescue Capybara::ElementNotFound
|
116
|
+
false
|
117
|
+
end
|
118
|
+
|
119
|
+
def has_element?(selector, wait = nil)
|
120
|
+
if wait
|
121
|
+
@session.has_selector?(selector, wait: wait)
|
122
|
+
else
|
123
|
+
@session.has_selector?(selector)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def click_button(button_text_or_selector)
|
128
|
+
@session.click_button(button_text_or_selector)
|
129
|
+
end
|
130
|
+
|
131
|
+
def click_link(link_text_or_selector)
|
132
|
+
@session.click_link(link_text_or_selector)
|
133
|
+
end
|
134
|
+
|
135
|
+
def right_click(selector)
|
136
|
+
@session.find(selector).right_click
|
137
|
+
end
|
138
|
+
|
139
|
+
def double_click(selector)
|
140
|
+
@session.find(selector).double_click
|
141
|
+
end
|
142
|
+
|
143
|
+
def hover(selector)
|
144
|
+
@session.find(selector).hover
|
145
|
+
end
|
146
|
+
|
147
|
+
def drag(source_selector, target_selector)
|
148
|
+
source = @session.find(source_selector)
|
149
|
+
target = @session.find(target_selector)
|
150
|
+
source.drag_to(target)
|
151
|
+
end
|
152
|
+
|
153
|
+
def find_all(selector)
|
154
|
+
@session.all(selector).map do |element|
|
155
|
+
{
|
156
|
+
tag_name: element.tag_name,
|
157
|
+
text: element.text,
|
158
|
+
visible: element.visible?,
|
159
|
+
attributes: extract_attributes(element),
|
160
|
+
value: element.value
|
161
|
+
}
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def get_current_url
|
166
|
+
@session.current_url
|
167
|
+
end
|
168
|
+
|
169
|
+
def get_current_path
|
170
|
+
@session.current_path
|
171
|
+
end
|
172
|
+
|
173
|
+
def get_page_title
|
174
|
+
@session.title
|
175
|
+
end
|
176
|
+
|
177
|
+
def get_page_source
|
178
|
+
@session.html
|
179
|
+
end
|
180
|
+
|
181
|
+
def get_window_handles
|
182
|
+
@session.windows.map(&:handle)
|
183
|
+
end
|
184
|
+
|
185
|
+
def switch_to_window(window_handle)
|
186
|
+
@session.switch_to_window(window_handle)
|
187
|
+
end
|
188
|
+
|
189
|
+
def resize_window(width, height)
|
190
|
+
@session.current_window.resize_to(width, height)
|
191
|
+
end
|
192
|
+
|
193
|
+
# JavaScript execution
|
194
|
+
def execute_script(script, *)
|
195
|
+
@session.execute_script(script, *)
|
196
|
+
end
|
197
|
+
|
198
|
+
def evaluate_script(script, *)
|
199
|
+
@session.evaluate_script(script, *)
|
200
|
+
end
|
201
|
+
|
202
|
+
# Screenshots
|
203
|
+
def save_screenshot(path, **)
|
204
|
+
@session.save_screenshot(path, **)
|
205
|
+
end
|
206
|
+
|
207
|
+
def save_page(path, **)
|
208
|
+
@session.save_page(path, **)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Window management
|
212
|
+
def current_window
|
213
|
+
@session.current_window
|
214
|
+
end
|
215
|
+
|
216
|
+
def windows
|
217
|
+
@session.windows
|
218
|
+
end
|
219
|
+
|
220
|
+
def open_new_window
|
221
|
+
new_window = @session.open_new_window
|
222
|
+
@session.switch_to_window(new_window)
|
223
|
+
new_window
|
224
|
+
end
|
225
|
+
|
226
|
+
def close_window(window)
|
227
|
+
current = @session.current_window
|
228
|
+
@session.switch_to_window(window)
|
229
|
+
window.close
|
230
|
+
@session.switch_to_window(current) if current.exists?
|
231
|
+
end
|
232
|
+
|
233
|
+
def window_handles
|
234
|
+
@session.windows.map(&:handle)
|
235
|
+
end
|
236
|
+
|
237
|
+
def maximize_window
|
238
|
+
@session.current_window.maximize
|
239
|
+
end
|
240
|
+
|
241
|
+
def resize_window_to(width, height)
|
242
|
+
@session.current_window.resize_to(width, height)
|
243
|
+
end
|
244
|
+
|
245
|
+
# Aliases to match our original Browser interface
|
246
|
+
alias resize_to resize_window_to
|
247
|
+
|
248
|
+
# Page information
|
249
|
+
def current_url
|
250
|
+
@session.current_url
|
251
|
+
end
|
252
|
+
|
253
|
+
def current_path
|
254
|
+
@session.current_path
|
255
|
+
end
|
256
|
+
|
257
|
+
def title
|
258
|
+
@session.title
|
259
|
+
end
|
260
|
+
|
261
|
+
def html
|
262
|
+
@session.html
|
263
|
+
end
|
264
|
+
|
265
|
+
def text
|
266
|
+
@session.text
|
267
|
+
end
|
268
|
+
|
269
|
+
# Capybara-specific delegations
|
270
|
+
def within(*, &)
|
271
|
+
@session.within(*, &)
|
272
|
+
end
|
273
|
+
|
274
|
+
def within_window(window, &)
|
275
|
+
@session.within_window(window, &)
|
276
|
+
end
|
277
|
+
|
278
|
+
def accept_alert(&)
|
279
|
+
@session.accept_alert(&)
|
280
|
+
end
|
281
|
+
|
282
|
+
def dismiss_alert(&)
|
283
|
+
@session.dismiss_alert(&)
|
284
|
+
end
|
285
|
+
|
286
|
+
def accept_confirm(&)
|
287
|
+
@session.accept_confirm(&)
|
288
|
+
end
|
289
|
+
|
290
|
+
def dismiss_confirm(&)
|
291
|
+
@session.dismiss_confirm(&)
|
292
|
+
end
|
293
|
+
|
294
|
+
# Driver access (for advanced operations)
|
295
|
+
def driver
|
296
|
+
@session.driver
|
297
|
+
end
|
298
|
+
|
299
|
+
# Cleanup
|
300
|
+
def quit
|
301
|
+
@session.quit
|
302
|
+
end
|
303
|
+
|
304
|
+
def reset!
|
305
|
+
@session.reset!
|
306
|
+
end
|
307
|
+
|
308
|
+
private
|
309
|
+
|
310
|
+
def extract_attributes(element)
|
311
|
+
# Get common attributes that are often useful
|
312
|
+
attrs = {}
|
313
|
+
%w[id class href src alt title name type value placeholder data-testid role aria-label].each do |attr|
|
314
|
+
value = element[attr]
|
315
|
+
attrs[attr] = value if value && !value.empty?
|
316
|
+
end
|
317
|
+
attrs
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
|
5
|
+
module HeadlessBrowserTool
|
6
|
+
class CLI < Thor
|
7
|
+
desc "start", "Start the headless browser and MCP server"
|
8
|
+
option :port, type: :numeric, default: 4567, desc: "Port for the MCP server"
|
9
|
+
option :headless, type: :boolean, default: true, desc: "Run browser in headless mode"
|
10
|
+
option :single_session, type: :boolean, default: false, desc: "Use single shared browser session (legacy mode)"
|
11
|
+
option :show_headers, type: :boolean, default: false, desc: "Show HTTP request headers for debugging"
|
12
|
+
option :session_id, type: :string, desc: "Session ID for persistence (only with --single-session)"
|
13
|
+
def start
|
14
|
+
require_relative "server"
|
15
|
+
Server.start_server(options)
|
16
|
+
end
|
17
|
+
|
18
|
+
desc "stdio", "Start the MCP server in stdio mode"
|
19
|
+
option :headless, type: :boolean, default: true, desc: "Run browser in headless mode"
|
20
|
+
def stdio
|
21
|
+
require_relative "stdio_server"
|
22
|
+
StdioServer.start(options)
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "version", "Display version"
|
26
|
+
def version
|
27
|
+
puts "HeadlessBrowserTool v#{VERSION}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.exit_on_failure?
|
31
|
+
true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|