browserctl 0.14.0 → 0.15.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +7 -3
  4. data/bin/browserctl +10 -2
  5. data/examples/tracing_otel.rb +46 -0
  6. data/lib/browserctl/callable_definition.rb +2 -2
  7. data/lib/browserctl/client.rb +41 -0
  8. data/lib/browserctl/commands/cookie.rb +17 -0
  9. data/lib/browserctl/commands/data.rb +73 -0
  10. data/lib/browserctl/commands/deprecation_notice.rb +33 -0
  11. data/lib/browserctl/commands/storage.rb +17 -0
  12. data/lib/browserctl/detectors/auth_required.rb +1 -1
  13. data/lib/browserctl/driver/cdp.rb +2 -2
  14. data/lib/browserctl/driver/ferrum_page_driver.rb +43 -0
  15. data/lib/browserctl/driver/page_driver.rb +90 -0
  16. data/lib/browserctl/error/codes.rb +15 -0
  17. data/lib/browserctl/error/exit_codes.rb +9 -1
  18. data/lib/browserctl/error/suggested_actions.rb +7 -0
  19. data/lib/browserctl/flow.rb +1 -1
  20. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +1 -1
  21. data/lib/browserctl/migrations.rb +2 -2
  22. data/lib/browserctl/replay/context.rb +1 -1
  23. data/lib/browserctl/replay/fingerprint_matcher.rb +1 -1
  24. data/lib/browserctl/server/command_dispatcher.rb +25 -17
  25. data/lib/browserctl/server/handlers/cookies.rb +18 -21
  26. data/lib/browserctl/server/handlers/data.rb +145 -0
  27. data/lib/browserctl/server/handlers/devtools.rb +8 -9
  28. data/lib/browserctl/server/handlers/hitl.rb +10 -0
  29. data/lib/browserctl/server/handlers/interaction.rb +21 -23
  30. data/lib/browserctl/server/handlers/navigation.rb +15 -15
  31. data/lib/browserctl/server/handlers/observation.rb +6 -6
  32. data/lib/browserctl/server/handlers/page_lifecycle.rb +12 -4
  33. data/lib/browserctl/server/handlers/state.rb +16 -6
  34. data/lib/browserctl/server/handlers/storage.rb +8 -8
  35. data/lib/browserctl/server/page_session.rb +21 -3
  36. data/lib/browserctl/server/plugin_dispatcher.rb +83 -0
  37. data/lib/browserctl/state/mutator.rb +1 -1
  38. data/lib/browserctl/tracing.rb +75 -0
  39. data/lib/browserctl/version.rb +1 -1
  40. data/lib/browserctl/workflow/page_proxy.rb +128 -0
  41. data/lib/browserctl/workflow.rb +3 -117
  42. data/lib/browserctl.rb +28 -2
  43. metadata +10 -1
@@ -16,7 +16,7 @@ module Browserctl
16
16
  # context so the surrounding workflow runner can render them into a
17
17
  # drift report at end-of-run.
18
18
  class Context
19
- DriftEvent = Struct.new(:command, :selector, :matched_ref, :score, :reason, keyword_init: true)
19
+ DriftEvent = Data.define(:command, :selector, :matched_ref, :score, :reason)
20
20
 
21
21
  attr_reader :drift_events
22
22
 
@@ -21,7 +21,7 @@ module Browserctl
21
21
  DEFAULT_THRESHOLD = 0.6
22
22
  WEIGHTS = { text: 0.40, role: 0.20, neighbors: 0.25, position: 0.15 }.freeze
23
23
 
24
- Match = Struct.new(:candidate, :score, keyword_init: true)
24
+ Match = Data.define(:candidate, :score)
25
25
 
26
26
  def initialize(threshold: DEFAULT_THRESHOLD, weights: WEIGHTS)
27
27
  @threshold = threshold
@@ -3,6 +3,7 @@
3
3
  require_relative "snapshot_builder"
4
4
  require_relative "page_session"
5
5
  require_relative "handlers/error_payload"
6
+ require_relative "plugin_dispatcher"
6
7
  require_relative "handlers/page_lifecycle"
7
8
  require_relative "handlers/navigation"
8
9
  require_relative "handlers/observation"
@@ -11,11 +12,13 @@ require_relative "handlers/hitl"
11
12
  require_relative "handlers/devtools"
12
13
  require_relative "handlers/daemon_control"
13
14
  require_relative "handlers/storage"
15
+ require_relative "handlers/data"
14
16
  require_relative "handlers/state"
15
17
  require_relative "handlers/interaction"
16
18
  require_relative "../detectors"
17
19
  require_relative "../policy"
18
20
  require_relative "../errors"
21
+ require_relative "../tracing"
19
22
  require_relative "../replay/snapshot_diff"
20
23
 
21
24
  module Browserctl
@@ -29,6 +32,7 @@ module Browserctl
29
32
  include Handlers::DevTools
30
33
  include Handlers::DaemonControl
31
34
  include Handlers::Storage
35
+ include Handlers::Data
32
36
  include Handlers::StateRpc
33
37
  include Handlers::Interaction
34
38
 
@@ -62,6 +66,10 @@ module Browserctl
62
66
  "storage_export" => :cmd_storage_export,
63
67
  "storage_import" => :cmd_storage_import,
64
68
  "storage_delete" => :cmd_storage_delete,
69
+ "data_get" => :cmd_data_get,
70
+ "data_set" => :cmd_data_set,
71
+ "data_delete" => :cmd_data_delete,
72
+ "data_list" => :cmd_data_list,
65
73
  "press" => :cmd_press,
66
74
  "hover" => :cmd_hover,
67
75
  "upload" => :cmd_upload,
@@ -80,12 +88,13 @@ module Browserctl
80
88
  SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
81
89
 
82
90
  def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
83
- @pages = pages
84
- @driver = driver
85
- @snapshot_builder = snapshot_builder
86
- @global_mutex = global_mutex
87
- @kv_store = {}
88
- @kv_mutex = Mutex.new
91
+ @pages = pages
92
+ @driver = driver
93
+ @snapshot_builder = snapshot_builder
94
+ @global_mutex = global_mutex
95
+ @kv_store = {}
96
+ @kv_mutex = Mutex.new
97
+ @plugin_dispatcher = PluginDispatcher.new(@pages, global_mutex: @global_mutex)
89
98
  end
90
99
 
91
100
  # Dispatches a parsed request to the appropriate handler.
@@ -93,13 +102,15 @@ module Browserctl
93
102
  # @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
94
103
  # @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
95
104
  def dispatch(req)
96
- builtin = dispatch_builtin(req)
97
- return builtin if builtin
105
+ Tracing.in_span("command.#{req[:cmd]}", attributes: { command: req[:cmd], page: req[:name] }) do
106
+ builtin = dispatch_builtin(req)
107
+ next builtin if builtin
98
108
 
99
- plugin = dispatch_plugin(req)
100
- return plugin if plugin
109
+ plugin = dispatch_plugin(req)
110
+ next plugin if plugin
101
111
 
102
- { error: "unknown command: #{req[:cmd]}" }
112
+ { error: "unknown command: #{req[:cmd]}" }
113
+ end
103
114
  end
104
115
 
105
116
  private
@@ -116,13 +127,10 @@ module Browserctl
116
127
 
117
128
  # Routes the request to a registered plugin command if one matches.
118
129
  # Returns the plugin response, or `nil` if no plugin handles `req[:cmd]`.
130
+ # Plugin invocation, timeout, and rescue boundary all live in
131
+ # {PluginDispatcher} — see v0.15 WS-2 PR 5.
119
132
  def dispatch_plugin(req)
120
- plugin = Browserctl.lookup_plugin_command(req[:cmd])
121
- return nil unless plugin
122
-
123
- Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
124
- session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
125
- plugin.call(session, req)
133
+ @plugin_dispatcher.dispatch(req)
126
134
  end
127
135
 
128
136
  def with_page(name)
@@ -7,38 +7,35 @@ module Browserctl
7
7
  private
8
8
 
9
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) }
10
+ with_page(req[:name]) do |session|
11
+ all = session.driver.cookies_all
12
+ { ok: true, cookies: all.values.map(&:to_h) }
13
+ end
15
14
  end
16
15
 
17
16
  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 }
17
+ with_page(req[:name]) do |session|
18
+ session.driver.cookies_set(
19
+ name: req[:cookie_name],
20
+ value: req[:value],
21
+ domain: req[:domain],
22
+ path: req.fetch(:path, "/")
23
+ )
24
+ { ok: true }
25
+ end
28
26
  end
29
27
 
30
28
  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 }
29
+ with_page(req[:name]) do |session|
30
+ session.driver.cookies_clear
31
+ { ok: true }
32
+ end
36
33
  end
37
34
 
38
35
  def cmd_import_cookies(req)
39
36
  with_page(req[:name]) do |session|
40
37
  req[:cookies].each do |c|
41
- session.page.cookies.set(
38
+ session.driver.cookies_set(
42
39
  name: c[:name],
43
40
  value: c[:value],
44
41
  domain: c[:domain],
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ # `data` is the unified verb family that subsumes `cookie *` and
7
+ # `storage *` (introduced in v0.15 via ADR-0021). Every operation takes
8
+ # a required `scope` ∈ {cookies, localStorage, sessionStorage} and
9
+ # returns a unified envelope shape — see docs/reference/commands.md
10
+ # for the full table.
11
+ #
12
+ # The legacy `cookies`, `set_cookie`, `delete_cookies`, `import_cookies`,
13
+ # `storage_*` handlers remain as aliases that delegate here so the old
14
+ # wire verbs keep working for the v0.15 deprecation window.
15
+ module Data
16
+ VALID_SCOPES = %w[cookies localStorage sessionStorage].freeze
17
+
18
+ private
19
+
20
+ # @return [Hash] unified response with `ok`, `scope`, plus per-op fields
21
+ def cmd_data_get(req)
22
+ scope = validate_scope(req[:scope])
23
+ return scope if scope.is_a?(Hash) # error envelope
24
+
25
+ case scope
26
+ when "cookies"
27
+ { error: "data get is not supported for scope 'cookies' — use 'data list'",
28
+ code: Browserctl::Error::Codes::INVALID_ARGUMENT }
29
+ when "localStorage", "sessionStorage"
30
+ with_page(req[:name]) do |session|
31
+ js = storage_get_js(scope, req[:key])
32
+ value = session.driver.evaluate(js)
33
+ { ok: true, scope: scope, key: req[:key], value: value }
34
+ end
35
+ end
36
+ end
37
+
38
+ def cmd_data_set(req)
39
+ scope = validate_scope(req[:scope])
40
+ return scope if scope.is_a?(Hash)
41
+
42
+ case scope
43
+ when "cookies"
44
+ with_page(req[:name]) do |session|
45
+ unless req[:domain]
46
+ next({ error: "data set --scope cookies requires --domain",
47
+ code: Browserctl::Error::Codes::INVALID_ARGUMENT })
48
+ end
49
+
50
+ session.driver.cookies_set(
51
+ name: req[:key],
52
+ value: req[:value],
53
+ domain: req[:domain],
54
+ path: req.fetch(:path, "/")
55
+ )
56
+ { ok: true, scope: scope, key: req[:key] }
57
+ end
58
+ when "localStorage", "sessionStorage"
59
+ with_page(req[:name]) do |session|
60
+ js = storage_set_js(scope, req[:key], req[:value])
61
+ session.driver.evaluate(js)
62
+ { ok: true, scope: scope, key: req[:key] }
63
+ end
64
+ end
65
+ end
66
+
67
+ def cmd_data_delete(req)
68
+ scope = validate_scope(req[:scope])
69
+ return scope if scope.is_a?(Hash)
70
+
71
+ case scope
72
+ when "cookies"
73
+ with_page(req[:name]) do |session|
74
+ count = session.driver.cookies_all.length
75
+ session.driver.cookies_clear
76
+ { ok: true, scope: scope, deleted: count }
77
+ end
78
+ when "localStorage"
79
+ with_page(req[:name]) do |session|
80
+ count = JSON.parse(session.driver.evaluate("JSON.stringify({...localStorage})") || "{}").length
81
+ session.driver.evaluate("localStorage.clear()")
82
+ { ok: true, scope: scope, deleted: count }
83
+ end
84
+ when "sessionStorage"
85
+ with_page(req[:name]) do |session|
86
+ count = JSON.parse(session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}").length
87
+ session.driver.evaluate("sessionStorage.clear()")
88
+ { ok: true, scope: scope, deleted: count }
89
+ end
90
+ end
91
+ end
92
+
93
+ def cmd_data_list(req)
94
+ scope = validate_scope(req[:scope])
95
+ return scope if scope.is_a?(Hash)
96
+
97
+ case scope
98
+ when "cookies"
99
+ with_page(req[:name]) do |session|
100
+ entries = session.driver.cookies_all.values.map(&:to_h)
101
+ { ok: true, scope: scope, entries: entries, count: entries.length }
102
+ end
103
+ when "localStorage", "sessionStorage"
104
+ with_page(req[:name]) do |session|
105
+ raw = session.driver.evaluate("JSON.stringify({...#{scope}})") || "{}"
106
+ parsed = JSON.parse(raw)
107
+ entries = parsed.map { |k, v| { key: k, value: v } }
108
+ { ok: true, scope: scope, entries: entries, count: entries.length }
109
+ end
110
+ end
111
+ end
112
+
113
+ # Returns the canonical scope string, or an error envelope on bad input.
114
+ def validate_scope(raw)
115
+ return invalid_scope_error(raw) if raw.nil?
116
+
117
+ scope = raw.to_s
118
+ # Accept short forms as v0.15-only wire aliases (per ADR-0021).
119
+ scope = "localStorage" if scope == "local"
120
+ scope = "sessionStorage" if scope == "session"
121
+ return invalid_scope_error(raw) unless VALID_SCOPES.include?(scope)
122
+
123
+ scope
124
+ end
125
+
126
+ def invalid_scope_error(raw)
127
+ {
128
+ error: "invalid --scope '#{raw}' — expected one of: #{VALID_SCOPES.join(', ')}",
129
+ code: Browserctl::Error::Codes::INVALID_ARGUMENT
130
+ }
131
+ end
132
+
133
+ def storage_get_js(scope, key)
134
+ target = scope == "localStorage" ? "localStorage" : "sessionStorage"
135
+ "#{target}.getItem(#{key.to_json})"
136
+ end
137
+
138
+ def storage_set_js(scope, key, value)
139
+ target = scope == "localStorage" ? "localStorage" : "sessionStorage"
140
+ "#{target}.setItem(#{key.to_json}, #{value.to_json})"
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -9,15 +9,14 @@ module Browserctl
9
9
  def cmd_devtools(req)
10
10
  return { error: "devtools is not supported by this driver" } unless @driver.supports?(:devtools)
11
11
 
12
- session = @global_mutex.synchronize { @pages[req[:name]] }
13
- return { error: "no page named '#{req[:name]}'" } unless session
14
-
15
- info = @driver.devtools_info(session.page)
16
- port = info[:port]
17
- target_id = info[:target_id]
18
- devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
19
- "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
20
- { ok: true, devtools_url: devtools_url }
12
+ with_page(req[:name]) do |session|
13
+ info = @driver.devtools_info(session.driver)
14
+ port = info[:port]
15
+ target_id = info[:target_id]
16
+ devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
17
+ "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
18
+ { ok: true, devtools_url: devtools_url }
19
+ end
21
20
  end
22
21
  end
23
22
  end
@@ -3,10 +3,18 @@
3
3
  module Browserctl
4
4
  class CommandDispatcher
5
5
  module Handlers
6
+ # HITL pause/resume cannot route through `with_page` because `with_page`
7
+ # acquires `session.mutex` and waits on `session.pause_cv` while paused.
8
+ # Pause sets that flag; resume signals the CV. Reentering `with_page`
9
+ # would deadlock — resume would never get the lock to clear the flag.
10
+ # So these handlers look up the session under `@global_mutex` directly,
11
+ # then manage `session.mutex` / `pause_cv` themselves.
6
12
  module Hitl
7
13
  private
8
14
 
9
15
  def cmd_pause(req)
16
+ # registry lookup: HITL manages session.mutex/pause_cv directly,
17
+ # cannot reenter with_page (would deadlock against pause_cv wait).
10
18
  session = @global_mutex.synchronize { @pages[req[:name]] }
11
19
  return { error: "no page named '#{req[:name]}'" } unless session
12
20
 
@@ -16,6 +24,8 @@ module Browserctl
16
24
  end
17
25
 
18
26
  def cmd_resume(req)
27
+ # registry lookup: HITL manages session.mutex/pause_cv directly,
28
+ # cannot reenter with_page (would deadlock against pause_cv wait).
19
29
  session = @global_mutex.synchronize { @pages[req[:name]] }
20
30
  return { error: "no page named '#{req[:name]}'" } unless session
21
31
 
@@ -8,8 +8,8 @@ module Browserctl
8
8
 
9
9
  def cmd_press(req)
10
10
  with_page(req[:name]) do |session|
11
- session.page.keyboard.down(req[:key])
12
- session.page.keyboard.up(req[:key])
11
+ session.driver.keyboard_down(req[:key])
12
+ session.driver.keyboard_up(req[:key])
13
13
  { ok: true }
14
14
  end
15
15
  end
@@ -19,7 +19,7 @@ module Browserctl
19
19
  sel = resolve_selector_from(session, req)
20
20
  return sel if sel.is_a?(Hash)
21
21
 
22
- coords = session.page.evaluate(
22
+ coords = session.driver.evaluate(
23
23
  "(function(sel) { " \
24
24
  "var el = document.querySelector(sel); " \
25
25
  "if (!el) return null; " \
@@ -35,7 +35,7 @@ module Browserctl
35
35
  )
36
36
  end
37
37
 
38
- session.page.mouse.move(x: coords["x"], y: coords["y"])
38
+ session.driver.mouse_move(x: coords["x"], y: coords["y"])
39
39
  { ok: true }
40
40
  end
41
41
  end
@@ -48,7 +48,7 @@ module Browserctl
48
48
  sel = resolve_selector_from(session, req)
49
49
  return sel if sel.is_a?(Hash)
50
50
 
51
- el = session.page.at_css(sel)
51
+ el = session.driver.at_css(sel)
52
52
  unless el
53
53
  return error_payload(
54
54
  code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
@@ -67,7 +67,7 @@ module Browserctl
67
67
  sel = resolve_selector_from(session, req)
68
68
  return sel if sel.is_a?(Hash)
69
69
 
70
- el = session.page.at_css(sel)
70
+ el = session.driver.at_css(sel)
71
71
  unless el
72
72
  return error_payload(
73
73
  code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
@@ -85,28 +85,26 @@ module Browserctl
85
85
  end
86
86
 
87
87
  def cmd_dialog_accept(req)
88
- session = @global_mutex.synchronize { @pages[req[:name]] }
89
- return { error: "no page named '#{req[:name]}'" } unless session
90
-
91
- text = req[:text]
92
- id = nil
93
- id = session.page.on(:dialog) do |dialog|
94
- session.page.off(:dialog, id)
95
- dialog.accept(text)
88
+ with_page(req[:name]) do |session|
89
+ text = req[:text]
90
+ id = nil
91
+ id = session.driver.on(:dialog) do |dialog|
92
+ session.driver.off(:dialog, id)
93
+ dialog.accept(text)
94
+ end
95
+ { ok: true }
96
96
  end
97
- { ok: true }
98
97
  end
99
98
 
100
99
  def cmd_dialog_dismiss(req)
101
- session = @global_mutex.synchronize { @pages[req[:name]] }
102
- return { error: "no page named '#{req[:name]}'" } unless session
103
-
104
- id = nil
105
- id = session.page.on(:dialog) do |dialog|
106
- session.page.off(:dialog, id)
107
- dialog.dismiss
100
+ with_page(req[:name]) do |session|
101
+ id = nil
102
+ id = session.driver.on(:dialog) do |dialog|
103
+ session.driver.off(:dialog, id)
104
+ dialog.dismiss
105
+ end
106
+ { ok: true }
108
107
  end
109
- { ok: true }
110
108
  end
111
109
  end
112
110
  end
@@ -19,20 +19,20 @@ module Browserctl
19
19
  end
20
20
 
21
21
  with_page(req[:name]) do |session|
22
- session.page.go_to(req[:url])
23
- { ok: true, url: session.page.current_url, challenge: Detectors.cloudflare?(session.page) }
22
+ session.driver.go_to(req[:url])
23
+ { ok: true, url: session.driver.current_url, challenge: Detectors.cloudflare?(session.driver) }
24
24
  end
25
25
  end
26
26
 
27
27
  def cmd_wait(req)
28
28
  with_page(req[:name]) do |session|
29
- result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
29
+ result = wait_for_selector(session.driver, req[:selector], req.fetch(:timeout, 30).to_f)
30
30
  result[:error] ? result : { ok: true, selector: req[:selector] }
31
31
  end
32
32
  end
33
33
 
34
34
  def cmd_evaluate(req)
35
- with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
35
+ with_page(req[:name]) { |session| { ok: true, result: session.driver.evaluate(req[:expression]) } }
36
36
  end
37
37
 
38
38
  def cmd_fill(req)
@@ -40,7 +40,7 @@ module Browserctl
40
40
  sel = resolve_selector_from(session, req)
41
41
  return sel if sel.is_a?(Hash)
42
42
 
43
- result = type_into(session.page, sel, req[:value])
43
+ result = type_into(session.driver, sel, req[:value])
44
44
  enrich_with_recording_metadata(result, session, sel, req)
45
45
  end
46
46
  end
@@ -50,7 +50,7 @@ module Browserctl
50
50
  sel = resolve_selector_from(session, req)
51
51
  return sel if sel.is_a?(Hash)
52
52
 
53
- result = click_element(session.page, sel)
53
+ result = click_element(session.driver, sel)
54
54
  enrich_with_recording_metadata(result, session, sel, req)
55
55
  end
56
56
  end
@@ -69,14 +69,14 @@ module Browserctl
69
69
  ref: ref,
70
70
  fingerprint: fp,
71
71
  snapshot_id: session.snapshot_id,
72
- postcondition_hint: { url: session.page.current_url }
72
+ postcondition_hint: { url: session.driver.current_url }
73
73
  )
74
74
  enriched[:post_snapshot_digest] = capture_post_snapshot_digest(session) if req[:capture_post_snapshot]
75
75
  enriched.compact
76
76
  end
77
77
 
78
78
  def capture_post_snapshot_digest(session)
79
- snapshot = @snapshot_builder.call(session.page)
79
+ snapshot = @snapshot_builder.call(session.driver)
80
80
  Browserctl::Replay::SnapshotDiff.digest(snapshot)
81
81
  rescue JSON::ParserError, Timeout::Error, Browserctl::Error => e
82
82
  Browserctl.logger.debug("post-snapshot digest skipped: #{e.class}: #{e.message}")
@@ -84,11 +84,11 @@ module Browserctl
84
84
  end
85
85
 
86
86
  def cmd_url(req)
87
- with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
87
+ with_page(req[:name]) { |session| { ok: true, url: session.driver.current_url } }
88
88
  end
89
89
 
90
- def type_into(page, selector, value)
91
- el = page.at_css(selector)
90
+ def type_into(driver, selector, value)
91
+ el = driver.at_css(selector)
92
92
  unless el
93
93
  return error_payload(
94
94
  code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
@@ -103,8 +103,8 @@ module Browserctl
103
103
  { ok: true }
104
104
  end
105
105
 
106
- def click_element(page, selector)
107
- el = page.at_css(selector)
106
+ def click_element(driver, selector)
107
+ el = driver.at_css(selector)
108
108
  unless el
109
109
  return error_payload(
110
110
  code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
@@ -127,10 +127,10 @@ module Browserctl
127
127
  session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
128
128
  end
129
129
 
130
- def wait_for_selector(page, selector, timeout)
130
+ def wait_for_selector(driver, selector, timeout)
131
131
  deadline = Time.now + timeout
132
132
  loop do
133
- found = page.at_css(selector)
133
+ found = driver.at_css(selector)
134
134
  break { ok: true } if found
135
135
  break { error: "wait timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
136
136
 
@@ -19,9 +19,9 @@ module Browserctl
19
19
  # bundle in hand (see PR 18); without them, only the URL signal fires.
20
20
  def cmd_auth_check(req)
21
21
  with_page(req[:name]) do |session|
22
- cookies = session.page.cookies.all.values.map(&:to_h) if req[:include_cookies]
22
+ cookies = session.driver.cookies_all.values.map(&:to_h) if req[:include_cookies]
23
23
  result = Browserctl::Detectors.auth_required(
24
- session.page,
24
+ session.driver,
25
25
  cookies: cookies,
26
26
  suggested_flow: req[:suggested_flow]
27
27
  )
@@ -38,11 +38,11 @@ module Browserctl
38
38
 
39
39
  def take_snapshot(session, format, diff)
40
40
  nonce = SecureRandom.hex(8)
41
- challenge = Detectors.cloudflare?(session.page)
41
+ challenge = Detectors.cloudflare?(session.driver)
42
42
 
43
- return { ok: true, html: session.page.body, challenge: challenge, nonce: nonce } unless format == "elements"
43
+ return { ok: true, html: session.driver.body, challenge: challenge, nonce: nonce } unless format == "elements"
44
44
 
45
- snapshot = @snapshot_builder.call(session.page)
45
+ snapshot = @snapshot_builder.call(session.driver)
46
46
  registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
47
47
  fp_index = build_fingerprint_index(snapshot)
48
48
 
@@ -84,7 +84,7 @@ module Browserctl
84
84
  return path if path.is_a?(Hash)
85
85
 
86
86
  FileUtils.mkdir_p(File.dirname(path))
87
- session.page.screenshot(path: path, full: req.fetch(:full, false))
87
+ session.driver.screenshot(path: path, full: req.fetch(:full, false))
88
88
  { ok: true, path: path }
89
89
  end
90
90
  end
@@ -1,26 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../driver/ferrum_page_driver"
4
+
3
5
  module Browserctl
4
6
  class CommandDispatcher
5
7
  module Handlers
8
+ # Page-lifecycle handlers are intrinsically registry-wide: they
9
+ # add to, remove from, or enumerate the `@pages` registry. They
10
+ # legitimately bypass `with_page` and hold `@global_mutex` directly.
6
11
  module PageLifecycle
7
12
  private
8
13
 
9
14
  def cmd_page_open(req)
15
+ # registry-wide: adds an entry to @pages, needs @global_mutex.
10
16
  session = @global_mutex.synchronize do
11
- @pages[req[:name]] ||= PageSession.new(@driver.create_page)
17
+ @pages[req[:name]] ||= PageSession.new(Browserctl::Driver::FerrumPageDriver.new(@driver.create_page))
12
18
  end
13
- session.page.go_to(req[:url]) if req[:url]
19
+ session.driver.go_to(req[:url]) if req[:url]
14
20
  { ok: true, name: req[:name] }
15
21
  end
16
22
 
17
23
  def cmd_page_close(req)
24
+ # registry-wide: removes an entry from @pages, needs @global_mutex.
18
25
  session = @global_mutex.synchronize { @pages.delete(req[:name]) }
19
- session&.page&.close
26
+ session&.driver&.close
20
27
  { ok: true }
21
28
  end
22
29
 
23
30
  def cmd_page_list(_req)
31
+ # registry-wide read: enumerates @pages keys (no per-page state).
24
32
  { pages: @global_mutex.synchronize { @pages.keys } }
25
33
  end
26
34
 
@@ -28,7 +36,7 @@ module Browserctl
28
36
  return { error: "page focus requires headed mode — start browserd with --headed" } unless @driver.headed?
29
37
 
30
38
  with_page(req[:name]) do |session|
31
- session.page.activate
39
+ session.driver.activate
32
40
  { ok: true }
33
41
  end
34
42
  end