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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +97 -55
  4. data/bin/browserctl +117 -108
  5. data/bin/browserd +9 -3
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +6 -6
  8. data/examples/smoke/params_file.rb +3 -2
  9. data/examples/smoke/store_fetch.rb +5 -5
  10. data/examples/test_automation_practices/checkboxes.rb +39 -0
  11. data/examples/test_automation_practices/dynamic_elements.rb +40 -0
  12. data/examples/test_automation_practices/key_press.rb +41 -0
  13. data/examples/test_automation_practices/login.rb +34 -0
  14. data/examples/test_automation_practices/login_negative.rb +28 -0
  15. data/examples/test_automation_practices/notifications.rb +57 -0
  16. data/examples/the_internet/add_remove_elements.rb +1 -1
  17. data/examples/the_internet/checkboxes.rb +1 -1
  18. data/examples/the_internet/dropdown.rb +1 -1
  19. data/examples/the_internet/dynamic_loading.rb +2 -2
  20. data/examples/the_internet/login.rb +1 -1
  21. data/lib/browserctl/client.rb +112 -28
  22. data/lib/browserctl/commands/cookie.rb +59 -0
  23. data/lib/browserctl/commands/daemon.rb +77 -0
  24. data/lib/browserctl/commands/page.rb +47 -0
  25. data/lib/browserctl/commands/record.rb +1 -1
  26. data/lib/browserctl/commands/screenshot.rb +2 -2
  27. data/lib/browserctl/commands/session.rb +69 -0
  28. data/lib/browserctl/commands/snapshot.rb +5 -5
  29. data/lib/browserctl/commands/storage.rb +67 -0
  30. data/lib/browserctl/commands/workflow.rb +64 -0
  31. data/lib/browserctl/constants.rb +20 -1
  32. data/lib/browserctl/detectors.rb +23 -0
  33. data/lib/browserctl/errors.rb +25 -0
  34. data/lib/browserctl/logger.rb +4 -4
  35. data/lib/browserctl/policy.rb +36 -0
  36. data/lib/browserctl/recording.rb +4 -4
  37. data/lib/browserctl/runner.rb +4 -4
  38. data/lib/browserctl/server/command_dispatcher.rb +49 -258
  39. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  40. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  41. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  42. data/lib/browserctl/server/handlers/hitl.rb +31 -0
  43. data/lib/browserctl/server/handlers/navigation.rb +94 -0
  44. data/lib/browserctl/server/handlers/observation.rb +87 -0
  45. data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
  46. data/lib/browserctl/server/handlers/session.rb +93 -0
  47. data/lib/browserctl/server/handlers/storage.rb +109 -0
  48. data/lib/browserctl/server.rb +4 -3
  49. data/lib/browserctl/session.rb +79 -0
  50. data/lib/browserctl/version.rb +1 -1
  51. data/lib/browserctl/workflow.rb +58 -17
  52. data/lib/browserctl.rb +12 -2
  53. metadata +43 -11
  54. data/lib/browserctl/commands/export_cookies.rb +0 -18
  55. data/lib/browserctl/commands/import_cookies.rb +0 -23
  56. data/lib/browserctl/commands/inspect.rb +0 -21
  57. data/lib/browserctl/commands/open_page.rb +0 -21
  58. data/lib/browserctl/commands/pause.rb +0 -22
  59. data/lib/browserctl/commands/status.rb +0 -30
  60. 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
- "open_page" => :cmd_open_page,
10
- "close_page" => :cmd_close_page,
11
- "list_pages" => :cmd_list_pages,
12
- "goto" => :cmd_goto,
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
- "inspect" => :cmd_inspect,
46
+ "devtools" => :cmd_devtools,
26
47
  "cookies" => :cmd_cookies,
27
48
  "set_cookie" => :cmd_set_cookie,
28
- "clear_cookies" => :cmd_clear_cookies,
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 = %w[.png .jpg .jpeg].freeze
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::PLUGIN_COMMANDS[req[:cmd]])
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