browserctl 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +97 -55
- data/bin/browserctl +117 -108
- data/bin/browserd +9 -3
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +6 -6
- data/examples/smoke/params_file.rb +3 -2
- data/examples/smoke/store_fetch.rb +5 -5
- data/examples/test_automation_practices/checkboxes.rb +39 -0
- data/examples/test_automation_practices/dynamic_elements.rb +40 -0
- data/examples/test_automation_practices/key_press.rb +41 -0
- data/examples/test_automation_practices/login.rb +34 -0
- data/examples/test_automation_practices/login_negative.rb +28 -0
- data/examples/test_automation_practices/notifications.rb +57 -0
- data/examples/the_internet/add_remove_elements.rb +1 -1
- data/examples/the_internet/checkboxes.rb +1 -1
- data/examples/the_internet/dropdown.rb +1 -1
- data/examples/the_internet/dynamic_loading.rb +2 -2
- data/examples/the_internet/login.rb +1 -1
- data/lib/browserctl/client.rb +112 -28
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/page.rb +47 -0
- data/lib/browserctl/commands/record.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/session.rb +69 -0
- data/lib/browserctl/commands/snapshot.rb +5 -5
- data/lib/browserctl/commands/storage.rb +67 -0
- data/lib/browserctl/commands/workflow.rb +64 -0
- data/lib/browserctl/constants.rb +20 -1
- data/lib/browserctl/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/logger.rb +4 -4
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/recording.rb +4 -4
- data/lib/browserctl/runner.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +49 -258
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +31 -0
- data/lib/browserctl/server/handlers/navigation.rb +94 -0
- data/lib/browserctl/server/handlers/observation.rb +87 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
- data/lib/browserctl/server/handlers/session.rb +93 -0
- data/lib/browserctl/server/handlers/storage.rb +109 -0
- data/lib/browserctl/server.rb +4 -3
- data/lib/browserctl/session.rb +79 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +58 -17
- data/lib/browserctl.rb +12 -2
- metadata +43 -11
- data/lib/browserctl/commands/export_cookies.rb +0 -18
- data/lib/browserctl/commands/import_cookies.rb +0 -23
- data/lib/browserctl/commands/inspect.rb +0 -21
- data/lib/browserctl/commands/open_page.rb +0 -21
- data/lib/browserctl/commands/pause.rb +0 -22
- data/lib/browserctl/commands/status.rb +0 -30
- data/lib/browserctl/commands/watch.rb +0 -27
|
@@ -2,50 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
4
|
require_relative "page_session"
|
|
5
|
+
require_relative "handlers/page_lifecycle"
|
|
6
|
+
require_relative "handlers/navigation"
|
|
7
|
+
require_relative "handlers/observation"
|
|
8
|
+
require_relative "handlers/cookies"
|
|
9
|
+
require_relative "handlers/hitl"
|
|
10
|
+
require_relative "handlers/devtools"
|
|
11
|
+
require_relative "handlers/daemon_control"
|
|
12
|
+
require_relative "handlers/storage"
|
|
13
|
+
require_relative "handlers/session"
|
|
14
|
+
require_relative "../detectors"
|
|
15
|
+
require_relative "../policy"
|
|
5
16
|
|
|
6
17
|
module Browserctl
|
|
7
18
|
class CommandDispatcher
|
|
19
|
+
include Handlers::PageLifecycle
|
|
20
|
+
include Handlers::Navigation
|
|
21
|
+
include Handlers::Observation
|
|
22
|
+
include Handlers::Cookies
|
|
23
|
+
include Handlers::Hitl
|
|
24
|
+
include Handlers::DevTools
|
|
25
|
+
include Handlers::DaemonControl
|
|
26
|
+
include Handlers::Storage
|
|
27
|
+
include Handlers::Session
|
|
28
|
+
|
|
8
29
|
COMMAND_MAP = {
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
30
|
+
"page_open" => :cmd_page_open,
|
|
31
|
+
"page_close" => :cmd_page_close,
|
|
32
|
+
"page_list" => :cmd_page_list,
|
|
33
|
+
"page_focus" => :cmd_page_focus,
|
|
34
|
+
"navigate" => :cmd_navigate,
|
|
35
|
+
"wait" => :cmd_wait,
|
|
13
36
|
"snapshot" => :cmd_snapshot,
|
|
14
37
|
"evaluate" => :cmd_evaluate,
|
|
15
38
|
"fill" => :cmd_fill,
|
|
16
39
|
"click" => :cmd_click,
|
|
17
40
|
"screenshot" => :cmd_screenshot,
|
|
18
|
-
"wait_for" => :cmd_wait_for,
|
|
19
|
-
"watch" => :cmd_watch,
|
|
20
41
|
"url" => :cmd_url,
|
|
21
42
|
"ping" => :cmd_ping,
|
|
22
43
|
"shutdown" => :cmd_shutdown,
|
|
23
44
|
"pause" => :cmd_pause,
|
|
24
45
|
"resume" => :cmd_resume,
|
|
25
|
-
"
|
|
46
|
+
"devtools" => :cmd_devtools,
|
|
26
47
|
"cookies" => :cmd_cookies,
|
|
27
48
|
"set_cookie" => :cmd_set_cookie,
|
|
28
|
-
"
|
|
29
|
-
"import_cookies" => :cmd_import_cookies
|
|
49
|
+
"delete_cookies" => :cmd_delete_cookies,
|
|
50
|
+
"import_cookies" => :cmd_import_cookies,
|
|
51
|
+
"store" => :cmd_store,
|
|
52
|
+
"fetch" => :cmd_fetch,
|
|
53
|
+
"storage_get" => :cmd_storage_get,
|
|
54
|
+
"storage_set" => :cmd_storage_set,
|
|
55
|
+
"storage_export" => :cmd_storage_export,
|
|
56
|
+
"storage_import" => :cmd_storage_import,
|
|
57
|
+
"storage_delete" => :cmd_storage_delete,
|
|
58
|
+
"session_save" => :cmd_session_save,
|
|
59
|
+
"session_load" => :cmd_session_load,
|
|
60
|
+
"session_list" => :cmd_session_list,
|
|
61
|
+
"session_delete" => :cmd_session_delete
|
|
30
62
|
}.freeze
|
|
31
63
|
|
|
32
64
|
SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
|
|
33
65
|
SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
|
|
34
|
-
SCREENSHOT_EXTS
|
|
35
|
-
CLOUDFLARE_SIGNALS = [
|
|
36
|
-
"cf-challenge-running",
|
|
37
|
-
"cf_chl_opt",
|
|
38
|
-
"__cf_chl_f_tk",
|
|
39
|
-
"Just a moment..."
|
|
40
|
-
].freeze
|
|
66
|
+
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
41
67
|
|
|
42
68
|
def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
43
69
|
@pages = pages
|
|
44
70
|
@browser = browser
|
|
45
71
|
@snapshot_builder = snapshot_builder
|
|
46
72
|
@global_mutex = global_mutex
|
|
73
|
+
@kv_store = {}
|
|
74
|
+
@kv_mutex = Mutex.new
|
|
47
75
|
end
|
|
48
76
|
|
|
77
|
+
# Dispatches a parsed request to the appropriate handler.
|
|
78
|
+
# Returns `{ error: String, code: String }` for unknown commands.
|
|
79
|
+
# @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
|
|
80
|
+
# @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
|
|
49
81
|
def dispatch(req)
|
|
50
82
|
handler = COMMAND_MAP[req[:cmd]]
|
|
51
83
|
if handler
|
|
@@ -53,7 +85,7 @@ module Browserctl
|
|
|
53
85
|
return send(handler, req)
|
|
54
86
|
end
|
|
55
87
|
|
|
56
|
-
if (plugin = Browserctl
|
|
88
|
+
if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
|
|
57
89
|
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
58
90
|
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
59
91
|
return plugin.call(session, req)
|
|
@@ -64,233 +96,6 @@ module Browserctl
|
|
|
64
96
|
|
|
65
97
|
private
|
|
66
98
|
|
|
67
|
-
def cmd_open_page(req)
|
|
68
|
-
session = @global_mutex.synchronize do
|
|
69
|
-
@pages[req[:name]] ||= PageSession.new(@browser.create_page)
|
|
70
|
-
end
|
|
71
|
-
session.page.go_to(req[:url]) if req[:url]
|
|
72
|
-
{ ok: true, name: req[:name] }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def cmd_close_page(req)
|
|
76
|
-
session = @global_mutex.synchronize { @pages.delete(req[:name]) }
|
|
77
|
-
session&.page&.close
|
|
78
|
-
{ ok: true }
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def cmd_list_pages(_req)
|
|
82
|
-
{ pages: @global_mutex.synchronize { @pages.keys } }
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def cmd_goto(req)
|
|
86
|
-
with_page(req[:name]) do |session|
|
|
87
|
-
session.page.go_to(req[:url])
|
|
88
|
-
{ ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def cmd_snapshot(req)
|
|
93
|
-
with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def take_snapshot(session, format, diff)
|
|
97
|
-
challenge = cloudflare_challenge?(session.page)
|
|
98
|
-
|
|
99
|
-
return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
|
|
100
|
-
|
|
101
|
-
snapshot = @snapshot_builder.call(session.page)
|
|
102
|
-
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
103
|
-
|
|
104
|
-
prev = session.prev_snapshot
|
|
105
|
-
session.ref_registry = registry
|
|
106
|
-
session.prev_snapshot = snapshot
|
|
107
|
-
result = diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
108
|
-
|
|
109
|
-
{ ok: true, snapshot: result, challenge: challenge }
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def compute_diff(prev, current)
|
|
113
|
-
prev_by_sel = prev.to_h { |el| [el[:selector], el] }
|
|
114
|
-
current.reject do |el|
|
|
115
|
-
old = prev_by_sel[el[:selector]]
|
|
116
|
-
old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def cmd_evaluate(req)
|
|
121
|
-
with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def cmd_fill(req)
|
|
125
|
-
with_page(req[:name]) do |session|
|
|
126
|
-
sel = resolve_selector_from(session, req)
|
|
127
|
-
return sel if sel.is_a?(Hash)
|
|
128
|
-
|
|
129
|
-
type_into(session.page, sel, req[:value])
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def type_into(page, selector, value)
|
|
134
|
-
el = page.at_css(selector)
|
|
135
|
-
return { error: "selector not found: #{selector}" } unless el
|
|
136
|
-
|
|
137
|
-
el.focus
|
|
138
|
-
el.type(value)
|
|
139
|
-
{ ok: true }
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def cmd_click(req)
|
|
143
|
-
with_page(req[:name]) do |session|
|
|
144
|
-
sel = resolve_selector_from(session, req)
|
|
145
|
-
return sel if sel.is_a?(Hash)
|
|
146
|
-
|
|
147
|
-
click_element(session.page, sel)
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def click_element(page, selector)
|
|
152
|
-
el = page.at_css(selector)
|
|
153
|
-
return { error: "selector not found: #{selector}" } unless el
|
|
154
|
-
|
|
155
|
-
el.click
|
|
156
|
-
{ ok: true }
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def cmd_screenshot(req)
|
|
160
|
-
with_page(req[:name]) do |session|
|
|
161
|
-
path = safe_screenshot_path(req[:path], req[:name])
|
|
162
|
-
return path if path.is_a?(Hash)
|
|
163
|
-
|
|
164
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
165
|
-
session.page.screenshot(path: path, full: req.fetch(:full, false))
|
|
166
|
-
{ ok: true, path: path }
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def safe_screenshot_path(requested, page_name)
|
|
171
|
-
if requested
|
|
172
|
-
expanded = File.expand_path(requested)
|
|
173
|
-
allowed = SCREENSHOT_ROOTS.any? { |d| expanded.start_with?("#{d}/") || expanded.start_with?(d) }
|
|
174
|
-
return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } unless allowed
|
|
175
|
-
return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
|
|
176
|
-
unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
|
|
177
|
-
|
|
178
|
-
expanded
|
|
179
|
-
else
|
|
180
|
-
name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
181
|
-
File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def cmd_wait_for(req)
|
|
186
|
-
with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def cmd_watch(req)
|
|
190
|
-
with_page(req[:name]) do |session|
|
|
191
|
-
result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
192
|
-
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def wait_for_selector(page, selector, timeout)
|
|
197
|
-
deadline = Time.now + timeout
|
|
198
|
-
loop do
|
|
199
|
-
found = page.at_css(selector)
|
|
200
|
-
break { ok: true } if found
|
|
201
|
-
break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
|
|
202
|
-
|
|
203
|
-
sleep 0.2
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def cmd_url(req)
|
|
208
|
-
with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def cmd_cookies(req)
|
|
212
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
213
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
214
|
-
|
|
215
|
-
all = session.page.cookies.all
|
|
216
|
-
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def cmd_set_cookie(req)
|
|
220
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
221
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
222
|
-
|
|
223
|
-
session.page.cookies.set(
|
|
224
|
-
name: req[:cookie_name],
|
|
225
|
-
value: req[:value],
|
|
226
|
-
domain: req[:domain],
|
|
227
|
-
path: req.fetch(:path, "/")
|
|
228
|
-
)
|
|
229
|
-
{ ok: true }
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def cmd_clear_cookies(req)
|
|
233
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
234
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
235
|
-
|
|
236
|
-
session.page.cookies.clear
|
|
237
|
-
{ ok: true }
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def cmd_import_cookies(req)
|
|
241
|
-
with_page(req[:name]) do |session|
|
|
242
|
-
req[:cookies].each do |c|
|
|
243
|
-
session.page.cookies.set(
|
|
244
|
-
name: c[:name],
|
|
245
|
-
value: c[:value],
|
|
246
|
-
domain: c[:domain],
|
|
247
|
-
path: c.fetch(:path, "/"),
|
|
248
|
-
httponly: c[:httpOnly] == true,
|
|
249
|
-
secure: c[:secure] == true,
|
|
250
|
-
expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
|
|
251
|
-
)
|
|
252
|
-
end
|
|
253
|
-
{ ok: true, count: req[:cookies].length }
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def cmd_inspect(req)
|
|
258
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
259
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
260
|
-
|
|
261
|
-
port = @browser.process.port
|
|
262
|
-
target_id = session.page.target_id
|
|
263
|
-
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
264
|
-
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
265
|
-
{ ok: true, devtools_url: devtools_url }
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def cmd_pause(req)
|
|
269
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
270
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
271
|
-
|
|
272
|
-
session.mutex.synchronize { session.pause! }
|
|
273
|
-
{ ok: true, paused: true }
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def cmd_resume(req)
|
|
277
|
-
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
278
|
-
return { error: "no page named '#{req[:name]}'" } unless session
|
|
279
|
-
|
|
280
|
-
session.mutex.synchronize do
|
|
281
|
-
session.resume!
|
|
282
|
-
session.pause_cv.signal
|
|
283
|
-
end
|
|
284
|
-
{ ok: true, paused: false }
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
|
|
288
|
-
|
|
289
|
-
def cmd_shutdown(_req)
|
|
290
|
-
Process.kill("INT", Process.pid)
|
|
291
|
-
{ ok: true }
|
|
292
|
-
end
|
|
293
|
-
|
|
294
99
|
def with_page(name)
|
|
295
100
|
session = @global_mutex.synchronize { @pages[name] }
|
|
296
101
|
return { error: "no page named '#{name}'" } unless session
|
|
@@ -300,19 +105,5 @@ module Browserctl
|
|
|
300
105
|
yield session
|
|
301
106
|
end
|
|
302
107
|
end
|
|
303
|
-
|
|
304
|
-
def cloudflare_challenge?(page)
|
|
305
|
-
url = page.current_url.to_s
|
|
306
|
-
body = page.body.to_s
|
|
307
|
-
url.include?("challenge-platform") ||
|
|
308
|
-
CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def resolve_selector_from(session, req)
|
|
312
|
-
return req[:selector] if req[:selector]
|
|
313
|
-
return { error: "selector or ref required" } unless req[:ref]
|
|
314
|
-
|
|
315
|
-
session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
316
|
-
end
|
|
317
108
|
end
|
|
318
109
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Cookies
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_cookies(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
all = session.page.cookies.all
|
|
14
|
+
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cmd_set_cookie(req)
|
|
18
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
19
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
20
|
+
|
|
21
|
+
session.page.cookies.set(
|
|
22
|
+
name: req[:cookie_name],
|
|
23
|
+
value: req[:value],
|
|
24
|
+
domain: req[:domain],
|
|
25
|
+
path: req.fetch(:path, "/")
|
|
26
|
+
)
|
|
27
|
+
{ ok: true }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cmd_delete_cookies(req)
|
|
31
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
32
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
33
|
+
|
|
34
|
+
session.page.cookies.clear
|
|
35
|
+
{ ok: true }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cmd_import_cookies(req)
|
|
39
|
+
with_page(req[:name]) do |session|
|
|
40
|
+
req[:cookies].each do |c|
|
|
41
|
+
session.page.cookies.set(
|
|
42
|
+
name: c[:name],
|
|
43
|
+
value: c[:value],
|
|
44
|
+
domain: c[:domain],
|
|
45
|
+
path: c.fetch(:path, "/"),
|
|
46
|
+
httponly: c[:httpOnly] == true,
|
|
47
|
+
secure: c[:secure] == true,
|
|
48
|
+
expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
{ ok: true, count: req[:cookies].length }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module DaemonControl
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
|
|
10
|
+
|
|
11
|
+
def cmd_shutdown(_req)
|
|
12
|
+
Process.kill("INT", Process.pid)
|
|
13
|
+
{ ok: true }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cmd_store(req)
|
|
17
|
+
@kv_mutex.synchronize { @kv_store[req[:key].to_s] = req[:value] }
|
|
18
|
+
{ ok: true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cmd_fetch(req)
|
|
22
|
+
key = req[:key].to_s
|
|
23
|
+
found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
|
|
24
|
+
found || { error: "key '#{key}' not found", code: "key_not_found" }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module DevTools
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_devtools(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
port = @browser.process.port
|
|
14
|
+
target_id = session.page.target_id
|
|
15
|
+
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
16
|
+
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
17
|
+
{ ok: true, devtools_url: devtools_url }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Hitl
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_pause(req)
|
|
10
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
|
+
|
|
13
|
+
session.mutex.synchronize { session.pause! }
|
|
14
|
+
Browserctl.logger.info("HITL pause: #{req[:message]}") if req[:message]
|
|
15
|
+
{ ok: true, paused: true, message: req[:message] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cmd_resume(req)
|
|
19
|
+
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
20
|
+
return { error: "no page named '#{req[:name]}'" } unless session
|
|
21
|
+
|
|
22
|
+
session.mutex.synchronize do
|
|
23
|
+
session.resume!
|
|
24
|
+
session.pause_cv.signal
|
|
25
|
+
end
|
|
26
|
+
{ ok: true, paused: false }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Navigation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_navigate(req)
|
|
10
|
+
unless Policy.allowed_navigation?(req[:url].to_s)
|
|
11
|
+
return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
with_page(req[:name]) do |session|
|
|
15
|
+
session.page.go_to(req[:url])
|
|
16
|
+
{ ok: true, url: session.page.current_url, challenge: Detectors.cloudflare?(session.page) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cmd_wait(req)
|
|
21
|
+
with_page(req[:name]) do |session|
|
|
22
|
+
result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
23
|
+
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cmd_evaluate(req)
|
|
28
|
+
with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cmd_fill(req)
|
|
32
|
+
with_page(req[:name]) do |session|
|
|
33
|
+
sel = resolve_selector_from(session, req)
|
|
34
|
+
return sel if sel.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
type_into(session.page, sel, req[:value])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cmd_click(req)
|
|
41
|
+
with_page(req[:name]) do |session|
|
|
42
|
+
sel = resolve_selector_from(session, req)
|
|
43
|
+
return sel if sel.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
click_element(session.page, sel)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cmd_url(req)
|
|
50
|
+
with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def type_into(page, selector, value)
|
|
54
|
+
el = page.at_css(selector)
|
|
55
|
+
return { error: "selector not found: #{selector}" } unless el
|
|
56
|
+
|
|
57
|
+
el.focus
|
|
58
|
+
el.evaluate("this.select()")
|
|
59
|
+
el.type(value)
|
|
60
|
+
{ ok: true }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def click_element(page, selector)
|
|
64
|
+
el = page.at_css(selector)
|
|
65
|
+
return { error: "selector not found: #{selector}" } unless el
|
|
66
|
+
|
|
67
|
+
# Use the DOM native click() so JS-only event listeners fire.
|
|
68
|
+
# CDP mouse simulation (el.click) dispatches events at screen coordinates
|
|
69
|
+
# and misses handlers on elements with no form submit chain.
|
|
70
|
+
el.evaluate("this.click()")
|
|
71
|
+
{ ok: true }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve_selector_from(session, req)
|
|
75
|
+
return req[:selector] if req[:selector]
|
|
76
|
+
return { error: "selector or ref required" } unless req[:ref]
|
|
77
|
+
|
|
78
|
+
session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def wait_for_selector(page, selector, timeout)
|
|
82
|
+
deadline = Time.now + timeout
|
|
83
|
+
loop do
|
|
84
|
+
found = page.at_css(selector)
|
|
85
|
+
break { ok: true } if found
|
|
86
|
+
break { error: "wait timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
|
|
87
|
+
|
|
88
|
+
sleep 0.2
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class CommandDispatcher
|
|
7
|
+
module Handlers
|
|
8
|
+
module Observation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def cmd_snapshot(req)
|
|
12
|
+
with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def take_snapshot(session, format, diff)
|
|
16
|
+
nonce = SecureRandom.hex(8)
|
|
17
|
+
challenge = Detectors.cloudflare?(session.page)
|
|
18
|
+
|
|
19
|
+
return { ok: true, html: session.page.body, challenge: challenge, nonce: nonce } unless format == "elements"
|
|
20
|
+
|
|
21
|
+
snapshot = @snapshot_builder.call(session.page)
|
|
22
|
+
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
23
|
+
|
|
24
|
+
prev = session.prev_snapshot
|
|
25
|
+
session.ref_registry = registry
|
|
26
|
+
session.prev_snapshot = snapshot
|
|
27
|
+
result = diff && prev ? compute_diff(prev, snapshot) : snapshot
|
|
28
|
+
|
|
29
|
+
{ ok: true, snapshot: result, challenge: challenge, nonce: nonce }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def compute_diff(prev, current)
|
|
33
|
+
prev_by_sel = prev.to_h { |el| [el[:selector], el] }
|
|
34
|
+
current.reject do |el|
|
|
35
|
+
old = prev_by_sel[el[:selector]]
|
|
36
|
+
old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cmd_screenshot(req)
|
|
41
|
+
with_page(req[:name]) do |session|
|
|
42
|
+
path = safe_screenshot_path(req[:path], req[:name])
|
|
43
|
+
return path if path.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
46
|
+
session.page.screenshot(path: path, full: req.fetch(:full, false))
|
|
47
|
+
{ ok: true, path: path }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def safe_screenshot_path(requested, page_name)
|
|
52
|
+
return default_screenshot_path(page_name) unless requested
|
|
53
|
+
|
|
54
|
+
expanded = File.expand_path(requested)
|
|
55
|
+
return { error: "invalid extension — use .png, .jpg, or .jpeg" } unless valid_screenshot_ext?(expanded)
|
|
56
|
+
return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } \
|
|
57
|
+
unless within_screenshot_roots?(expanded)
|
|
58
|
+
|
|
59
|
+
File.join(resolve_dir(File.dirname(expanded)), File.basename(expanded))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def valid_screenshot_ext?(path)
|
|
63
|
+
SCREENSHOT_EXTS.include?(File.extname(path).downcase)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def within_screenshot_roots?(path)
|
|
67
|
+
dir = resolve_dir(File.dirname(path))
|
|
68
|
+
SCREENSHOT_ROOTS.any? do |root|
|
|
69
|
+
real_root = resolve_dir(root)
|
|
70
|
+
dir.start_with?("#{real_root}/") || dir == real_root
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve_dir(dir)
|
|
75
|
+
File.realpath(dir)
|
|
76
|
+
rescue Errno::ENOENT
|
|
77
|
+
dir
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def default_screenshot_path(page_name)
|
|
81
|
+
name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
82
|
+
File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|