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
@@ -12,6 +12,9 @@ module Browserctl
12
12
  private
13
13
 
14
14
  def cmd_state_save(req)
15
+ # registry-wide: state save snapshots across all open pages,
16
+ # not a single addressed page — picks an arbitrary first session
17
+ # for cookie capture (cookies are browser-global in CDP).
15
18
  first_session = @global_mutex.synchronize { @pages.values.first }
16
19
  return { error: "no open pages — open a page before saving state" } unless first_session
17
20
 
@@ -40,6 +43,8 @@ module Browserctl
40
43
 
41
44
  def cmd_state_load(req)
42
45
  data = Browserctl::State.load(req[:name], passphrase: req[:passphrase])
46
+ # registry-wide: state load applies to any open page (cookies are
47
+ # browser-global); first session is fine as the cookie sink.
43
48
  target = @global_mutex.synchronize { @pages.values.first }
44
49
  return { error: "no open pages — open a page before loading state" } unless target
45
50
 
@@ -47,7 +52,7 @@ module Browserctl
47
52
 
48
53
  unless req[:skip_auth_check]
49
54
  auth = Browserctl::Detectors.auth_required(
50
- target.page, cookies: cookies, suggested_flow: data[:manifest][:flow]
55
+ target.driver, cookies: cookies, suggested_flow: data[:manifest][:flow]
51
56
  )
52
57
  if auth.triggered
53
58
  return Browserctl::AuthRequiredError.new(
@@ -79,7 +84,7 @@ module Browserctl
79
84
  def restore_state_cookies(target, cookies)
80
85
  cookies.each do |raw|
81
86
  c = raw.transform_keys(&:to_sym)
82
- target.page.cookies.set(**c.slice(:name, :value, :domain, :path))
87
+ target.driver.cookies_set(**c.slice(:name, :value, :domain, :path))
83
88
  end
84
89
  end
85
90
 
@@ -101,18 +106,23 @@ module Browserctl
101
106
  end
102
107
 
103
108
  def capture_state_payload
109
+ # registry-wide: cookies are browser-global, so any session works
110
+ # as the cookie source.
104
111
  first = @global_mutex.synchronize { @pages.values.first }
105
- cookies = first.page.cookies.all.values.map(&:to_h)
112
+ cookies = first.driver.cookies_all.values.map(&:to_h)
106
113
 
107
114
  local_storage = {}
108
115
  session_storage = {}
109
116
  captured_origins = []
110
117
 
118
+ # registry-wide: iterate every page to capture per-origin storage;
119
+ # snapshot the registry under @global_mutex, then take each
120
+ # session's own mutex to read storage safely.
111
121
  @global_mutex.synchronize { @pages.dup }.each_value do |session|
112
122
  session.mutex.synchronize do
113
- origin = session.page.evaluate("location.origin")
114
- ls_str = session.page.evaluate("JSON.stringify({...localStorage})") || "{}"
115
- ss_str = session.page.evaluate("JSON.stringify({...sessionStorage})") || "{}"
123
+ origin = session.driver.evaluate("location.origin")
124
+ ls_str = session.driver.evaluate("JSON.stringify({...localStorage})") || "{}"
125
+ ss_str = session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}"
116
126
  local_storage[origin] = JSON.parse(ls_str)
117
127
  session_storage[origin] = JSON.parse(ss_str)
118
128
  captured_origins << origin
@@ -13,7 +13,7 @@ module Browserctl
13
13
  js = storage_js_get(store, req[:key])
14
14
  return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
15
15
 
16
- value = session.page.evaluate(js)
16
+ value = session.driver.evaluate(js)
17
17
  { ok: true, value: value }
18
18
  end
19
19
  end
@@ -25,7 +25,7 @@ module Browserctl
25
25
  js = storage_js_set(store, req[:key], req[:value])
26
26
  return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
27
27
 
28
- session.page.evaluate(js)
28
+ session.driver.evaluate(js)
29
29
  { ok: true }
30
30
  end
31
31
  end
@@ -37,16 +37,16 @@ module Browserctl
37
37
  stores = req.fetch(:stores, "all")
38
38
  data = {}
39
39
 
40
- origin = session.page.evaluate("location.origin")
40
+ origin = session.driver.evaluate("location.origin")
41
41
  data[origin] = {}
42
42
 
43
43
  if %w[local all].include?(stores)
44
- local = JSON.parse(session.page.evaluate("JSON.stringify({...localStorage})") || "{}")
44
+ local = JSON.parse(session.driver.evaluate("JSON.stringify({...localStorage})") || "{}")
45
45
  data[origin].merge!(local)
46
46
  end
47
47
 
48
48
  if %w[session all].include?(stores)
49
- sess = JSON.parse(session.page.evaluate("JSON.stringify({...sessionStorage})") || "{}")
49
+ sess = JSON.parse(session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}")
50
50
  data[origin].merge!(sess)
51
51
  end
52
52
 
@@ -71,7 +71,7 @@ module Browserctl
71
71
  key_count = 0
72
72
  data.each_value do |keys|
73
73
  keys.each do |k, v|
74
- session.page.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
74
+ session.driver.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
75
75
  key_count += 1
76
76
  end
77
77
  end
@@ -84,8 +84,8 @@ module Browserctl
84
84
  def cmd_storage_delete(req)
85
85
  with_page(req[:name]) do |session|
86
86
  stores = req.fetch(:stores, "all")
87
- session.page.evaluate("localStorage.clear()") if %w[local all].include?(stores)
88
- session.page.evaluate("sessionStorage.clear()") if %w[session all].include?(stores)
87
+ session.driver.evaluate("localStorage.clear()") if %w[local all].include?(stores)
88
+ session.driver.evaluate("sessionStorage.clear()") if %w[session all].include?(stores)
89
89
  { ok: true }
90
90
  end
91
91
  end
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../driver/ferrum_page_driver"
4
+
3
5
  module Browserctl
4
6
  class PageSession
5
- attr_reader :page, :mutex, :pause_cv
7
+ attr_reader :driver, :mutex, :pause_cv
6
8
  attr_accessor :ref_registry, :prev_snapshot, :fingerprint_index, :snapshot_id
7
9
 
8
- def initialize(page)
9
- @page = page
10
+ # @param page_or_driver [Browserctl::Driver::PageDriver, Object] either a
11
+ # PageDriver (preferred) or a raw Ferrum/CDP page (auto-wrapped in
12
+ # {Browserctl::Driver::FerrumPageDriver} for back-compat with callers
13
+ # that still construct sessions from a raw page).
14
+ def initialize(page_or_driver)
15
+ @driver = if page_or_driver.is_a?(Browserctl::Driver::PageDriver)
16
+ page_or_driver
17
+ else
18
+ Browserctl::Driver::FerrumPageDriver.new(page_or_driver)
19
+ end
10
20
  @mutex = Mutex.new
11
21
  @pause_cv = ConditionVariable.new
12
22
  @ref_registry = {}
@@ -16,6 +26,14 @@ module Browserctl
16
26
  @paused = false
17
27
  end
18
28
 
29
+ # Back-compat accessor. New handler code calls {#driver} directly; this
30
+ # returns the underlying Ferrum/CDP page for legacy callers (the CDP
31
+ # driver's `devtools_info`, the page-lifecycle close path, and a unit
32
+ # spec).
33
+ def page
34
+ @driver.respond_to?(:raw_page) ? @driver.raw_page : @driver
35
+ end
36
+
19
37
  def paused? = @paused
20
38
  def pause! = (@paused = true)
21
39
  def resume! = (@paused = false)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require_relative "handlers/error_payload"
6
+ require_relative "../errors"
7
+
8
+ module Browserctl
9
+ # Invokes plugin commands registered via {Browserctl.register_command} from
10
+ # the daemon's command-dispatch loop. Extracted from `CommandDispatcher` in
11
+ # v0.15 WS-2 PR 5 so plugin invocation gets two daemon-protecting
12
+ # properties:
13
+ #
14
+ # - A per-plugin wall-clock timeout (default
15
+ # {Browserctl::DEFAULT_PLUGIN_TIMEOUT}, configurable via `timeout:` on
16
+ # `register_command`, opt-out with `timeout: nil`). On expiry the
17
+ # dispatcher returns a typed `PLUGIN_TIMED_OUT` response and the daemon
18
+ # stays answering subsequent commands.
19
+ # - A rescue boundary that converts ANY uncaught exception from the plugin
20
+ # block into a typed `PLUGIN_FAILED` JSON-RPC response. The plugin name
21
+ # is always present in the response's `context` so agents can branch on
22
+ # it. The daemon process is never taken down by a buggy plugin.
23
+ #
24
+ # Plugins live in the Extension zone (see
25
+ # `docs/reference/api-stability.md`); the response shape here is documented
26
+ # in `docs/reference/errors.md`.
27
+ class PluginDispatcher
28
+ include CommandDispatcher::Handlers::ErrorPayload
29
+
30
+ # @param pages [Hash{String => PageSession}] shared daemon page registry
31
+ # @param global_mutex [Mutex] mutex guarding the page registry
32
+ def initialize(pages, global_mutex:)
33
+ @pages = pages
34
+ @global_mutex = global_mutex
35
+ end
36
+
37
+ # Looks up the plugin command for `req[:cmd]` and invokes it under the
38
+ # timeout / rescue boundary. Returns `nil` if no plugin handles this
39
+ # command — the caller falls through to its "unknown command" branch.
40
+ #
41
+ # @param req [Hash{Symbol => Object}] parsed request
42
+ # @return [Hash{Symbol => Object}, nil]
43
+ def dispatch(req)
44
+ plugin = Browserctl.lookup_plugin_command(req[:cmd])
45
+ return nil unless plugin
46
+
47
+ name = req[:cmd].to_s
48
+ session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
49
+
50
+ Browserctl.logger.debug("plugin:#{name} #{req[:name]}")
51
+ invoke(plugin, name, session, req)
52
+ end
53
+
54
+ private
55
+
56
+ def invoke(plugin, name, session, req)
57
+ response = if plugin.timeout
58
+ Timeout.timeout(plugin.timeout) { plugin.block.call(session, req) }
59
+ else
60
+ plugin.block.call(session, req)
61
+ end
62
+ # Verify the response will survive the daemon's JSON serialiser. A plugin
63
+ # that returns an unencodable object would otherwise blow up at the
64
+ # socket-write step, outside this rescue boundary.
65
+ JSON.generate(response)
66
+ response
67
+ rescue Timeout::Error
68
+ Browserctl.logger.warn("plugin:#{name} timed out after #{plugin.timeout}s")
69
+ error_payload(
70
+ code: Browserctl::Error::Codes::PLUGIN_TIMED_OUT,
71
+ message: "plugin '#{name}' timed out after #{plugin.timeout}s",
72
+ context: { plugin: name, timeout: plugin.timeout }
73
+ )
74
+ rescue StandardError, ScriptError => e
75
+ Browserctl.logger.warn("plugin:#{name} raised #{e.class}: #{e.message}")
76
+ error_payload(
77
+ code: Browserctl::Error::Codes::PLUGIN_FAILED,
78
+ message: "plugin '#{name}' failed: #{e.class}: #{e.message}",
79
+ context: { plugin: name, exception: e.class.name }
80
+ )
81
+ end
82
+ end
83
+ end
@@ -16,7 +16,7 @@ module Browserctl
16
16
  # caller (CLI or Workflow) can decide how to render them. The CLI maps
17
17
  # to a non-zero exit; a Workflow caller may catch and continue.
18
18
  class Mutator
19
- Result = Struct.new(:save_result, :flow_name, :flow_version, keyword_init: true) do
19
+ Result = Data.define(:save_result, :flow_name, :flow_version) do
20
20
  def to_h
21
21
  (save_result || {}).merge(rotated_flow: flow_name)
22
22
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ # Pluggable tracing seam shaped like OpenTelemetry's span API, without
5
+ # taking an OpenTelemetry dependency.
6
+ #
7
+ # Backend contract
8
+ # ----------------
9
+ #
10
+ # A tracing backend must respond to two methods:
11
+ #
12
+ # start_span(name, attributes:) -> span
13
+ # - `name` is a String (e.g. "command.navigate").
14
+ # - `attributes` is a Hash of additional span tags.
15
+ # - Returns an opaque span object that will be passed back to
16
+ # `end_span`. May return `nil`; `end_span` must tolerate that.
17
+ #
18
+ # end_span(span, status:, attributes: {})
19
+ # - `span` is whatever `start_span` returned.
20
+ # - `status` is `:ok` or `:error`.
21
+ # - `attributes` carries any tags computed at close time
22
+ # (notably `duration_ms`). Optional.
23
+ #
24
+ # The default backend is `NoopBackend` — it does nothing. A custom
25
+ # backend is wired in via `Browserctl::Tracing.backend = ...`.
26
+ # The contract is part of the Extension zone — see
27
+ # `docs/reference/api-stability.md`.
28
+ module Tracing
29
+ # No-op default. Implements the Backend contract above.
30
+ class NoopBackend
31
+ def start_span(_name, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
32
+ nil
33
+ end
34
+
35
+ def end_span(_span, status: :ok, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
36
+ nil
37
+ end
38
+ end
39
+
40
+ @backend_mutex = Mutex.new
41
+ @backend = NoopBackend.new
42
+
43
+ class << self
44
+ def backend
45
+ @backend_mutex.synchronize { @backend }
46
+ end
47
+
48
+ def backend=(value)
49
+ @backend_mutex.synchronize { @backend = value || NoopBackend.new }
50
+ end
51
+
52
+ # Wraps a block in a span. Computes `duration_ms` and routes
53
+ # exceptions to `end_span(span, status: :error)`.
54
+ def in_span(name, attributes: {})
55
+ bk = backend
56
+ span = bk.start_span(name, attributes: attributes)
57
+ start = monotonic_ms
58
+ begin
59
+ result = yield
60
+ rescue StandardError
61
+ bk.end_span(span, status: :error, attributes: { duration_ms: monotonic_ms - start })
62
+ raise
63
+ end
64
+ bk.end_span(span, status: :ok, attributes: { duration_ms: monotonic_ms - start })
65
+ result
66
+ end
67
+
68
+ private
69
+
70
+ def monotonic_ms
71
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../replay/fingerprint_matcher"
5
+
6
+ module Browserctl
7
+ # Per-page wrapper handed to workflow step blocks via `page(:name)`. Forwards
8
+ # one-liners to the daemon `Client`, unwraps the response, and — when a replay
9
+ # context is attached — falls back to fingerprint-based rematching when a
10
+ # selector goes missing.
11
+ #
12
+ # Extracted from `workflow.rb` so the proxy can be loaded and reasoned about
13
+ # independently of the workflow DSL / registry; behaviour is unchanged.
14
+ class PageProxy
15
+ attr_accessor :replay_context
16
+
17
+ # Declarative wrapper for `unwrap @client.METHOD(@name, ...)` one-liners.
18
+ # Forwards positional + keyword args verbatim. Pass `extract:` to return
19
+ # a single key from the client response instead of unwrapping.
20
+ def self.delegate_unwrap(method_name, extract: nil)
21
+ if extract
22
+ define_method(method_name) do |*args, **kwargs|
23
+ @client.public_send(method_name, @name, *args, **kwargs)[extract]
24
+ end
25
+ else
26
+ define_method(method_name) do |*args, **kwargs|
27
+ unwrap @client.public_send(method_name, @name, *args, **kwargs)
28
+ end
29
+ end
30
+ end
31
+
32
+ def initialize(name, client, replay_context: nil, matcher: nil)
33
+ @name = name
34
+ @client = client
35
+ @replay_context = replay_context
36
+ @matcher = matcher || Replay::FingerprintMatcher.new
37
+ end
38
+
39
+ delegate_unwrap :navigate
40
+ delegate_unwrap :snapshot
41
+ delegate_unwrap :screenshot
42
+ delegate_unwrap :wait
43
+ delegate_unwrap :delete_cookies
44
+ delegate_unwrap :press
45
+ delegate_unwrap :storage_set
46
+ delegate_unwrap :dialog_accept
47
+ delegate_unwrap :dialog_dismiss
48
+
49
+ delegate_unwrap :devtools, extract: :devtools_url
50
+ delegate_unwrap :url, extract: :url
51
+ delegate_unwrap :evaluate, extract: :result
52
+ delegate_unwrap :storage_get, extract: :value
53
+
54
+ def fill(selector = nil, value = nil, ref: nil)
55
+ with_selector_fallback(:fill, selector, ref) do |sel, r|
56
+ @client.fill(@name, sel, value, ref: r)
57
+ end
58
+ end
59
+
60
+ def click(selector = nil, ref: nil)
61
+ with_selector_fallback(:click, selector, ref) do |sel, r|
62
+ @client.click(@name, sel, ref: r)
63
+ end
64
+ end
65
+
66
+ def hover(selector = nil, ref: nil)
67
+ with_selector_fallback(:hover, selector, ref) do |sel, r|
68
+ @client.hover(@name, sel, ref: r)
69
+ end
70
+ end
71
+
72
+ def upload(selector = nil, path = nil, ref: nil)
73
+ with_selector_fallback(:upload, selector, ref) do |sel, r|
74
+ @client.upload(@name, sel, path, ref: r)
75
+ end
76
+ end
77
+
78
+ def select(selector = nil, value = nil, ref: nil)
79
+ with_selector_fallback(:select, selector, ref) do |sel, r|
80
+ @client.select(@name, sel, value, ref: r)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Issues the wrapped command. If the daemon returns selector_not_found
87
+ # and a replay context has a fingerprint for this selector, takes a
88
+ # fresh snapshot, asks the matcher for a candidate, and retries by ref.
89
+ def with_selector_fallback(cmd, selector, ref)
90
+ res = yield(selector, ref)
91
+ return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
92
+
93
+ fp = @replay_context.fingerprint_for(selector)
94
+ return unwrap(res) unless fp
95
+
96
+ match = @matcher.best(fp, snapshot_entries)
97
+ unless match
98
+ @replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
99
+ return unwrap(res)
100
+ end
101
+
102
+ log_rematch(cmd, selector, match)
103
+ @replay_context.record(command: cmd, selector: selector,
104
+ matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
105
+ unwrap(yield(nil, match.candidate[:ref]))
106
+ end
107
+
108
+ def snapshot_entries
109
+ res = @client.snapshot(@name, format: "elements")
110
+ Array(res[:snapshot])
111
+ end
112
+
113
+ def selector_not_found?(res)
114
+ res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
115
+ end
116
+
117
+ def log_rematch(cmd, selector, match)
118
+ warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
119
+ "rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
120
+ end
121
+
122
+ def unwrap(res)
123
+ raise WorkflowError, res[:error] if res[:error]
124
+
125
+ res
126
+ end
127
+ end
128
+ end
@@ -8,6 +8,7 @@ require_relative "flow_registry"
8
8
  require_relative "replay/context"
9
9
  require_relative "replay/fingerprint_matcher"
10
10
  require_relative "replay/snapshot_diff"
11
+ require_relative "workflow/page_proxy"
11
12
 
12
13
  module Browserctl
13
14
  # Workflow-file format version. Workflows are Ruby files; the schema gate
@@ -69,7 +70,7 @@ module Browserctl
69
70
  # workflow specs reference these directly).
70
71
  ParamDef = CallableDefinition::ParamDef
71
72
  StepDef = CallableDefinition::StepDef
72
- StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
73
+ StepResult = Data.define(:name, :ok, :error)
73
74
 
74
75
  class WorkflowContext
75
76
  include ContextualPersistence
@@ -193,121 +194,6 @@ module Browserctl
193
194
  end
194
195
  end
195
196
 
196
- class PageProxy
197
- attr_accessor :replay_context
198
-
199
- # Declarative wrapper for `unwrap @client.METHOD(@name, ...)` one-liners.
200
- # Forwards positional + keyword args verbatim. Pass `extract:` to return
201
- # a single key from the client response instead of unwrapping.
202
- def self.delegate_unwrap(method_name, extract: nil)
203
- if extract
204
- define_method(method_name) do |*args, **kwargs|
205
- @client.public_send(method_name, @name, *args, **kwargs)[extract]
206
- end
207
- else
208
- define_method(method_name) do |*args, **kwargs|
209
- unwrap @client.public_send(method_name, @name, *args, **kwargs)
210
- end
211
- end
212
- end
213
-
214
- def initialize(name, client, replay_context: nil, matcher: nil)
215
- @name = name
216
- @client = client
217
- @replay_context = replay_context
218
- @matcher = matcher || Replay::FingerprintMatcher.new
219
- end
220
-
221
- delegate_unwrap :navigate
222
- delegate_unwrap :snapshot
223
- delegate_unwrap :screenshot
224
- delegate_unwrap :wait
225
- delegate_unwrap :delete_cookies
226
- delegate_unwrap :press
227
- delegate_unwrap :storage_set
228
- delegate_unwrap :dialog_accept
229
- delegate_unwrap :dialog_dismiss
230
-
231
- delegate_unwrap :devtools, extract: :devtools_url
232
- delegate_unwrap :url, extract: :url
233
- delegate_unwrap :evaluate, extract: :result
234
- delegate_unwrap :storage_get, extract: :value
235
-
236
- def fill(selector = nil, value = nil, ref: nil)
237
- with_selector_fallback(:fill, selector, ref) do |sel, r|
238
- @client.fill(@name, sel, value, ref: r)
239
- end
240
- end
241
-
242
- def click(selector = nil, ref: nil)
243
- with_selector_fallback(:click, selector, ref) do |sel, r|
244
- @client.click(@name, sel, ref: r)
245
- end
246
- end
247
-
248
- def hover(selector = nil, ref: nil)
249
- with_selector_fallback(:hover, selector, ref) do |sel, r|
250
- @client.hover(@name, sel, ref: r)
251
- end
252
- end
253
-
254
- def upload(selector = nil, path = nil, ref: nil)
255
- with_selector_fallback(:upload, selector, ref) do |sel, r|
256
- @client.upload(@name, sel, path, ref: r)
257
- end
258
- end
259
-
260
- def select(selector = nil, value = nil, ref: nil)
261
- with_selector_fallback(:select, selector, ref) do |sel, r|
262
- @client.select(@name, sel, value, ref: r)
263
- end
264
- end
265
-
266
- private
267
-
268
- # Issues the wrapped command. If the daemon returns selector_not_found
269
- # and a replay context has a fingerprint for this selector, takes a
270
- # fresh snapshot, asks the matcher for a candidate, and retries by ref.
271
- def with_selector_fallback(cmd, selector, ref)
272
- res = yield(selector, ref)
273
- return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
274
-
275
- fp = @replay_context.fingerprint_for(selector)
276
- return unwrap(res) unless fp
277
-
278
- match = @matcher.best(fp, snapshot_entries)
279
- unless match
280
- @replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
281
- return unwrap(res)
282
- end
283
-
284
- log_rematch(cmd, selector, match)
285
- @replay_context.record(command: cmd, selector: selector,
286
- matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
287
- unwrap(yield(nil, match.candidate[:ref]))
288
- end
289
-
290
- def snapshot_entries
291
- res = @client.snapshot(@name, format: "elements")
292
- Array(res[:snapshot])
293
- end
294
-
295
- def selector_not_found?(res)
296
- res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
297
- end
298
-
299
- def log_rematch(cmd, selector, match)
300
- warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
301
- "rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
302
- end
303
-
304
- def unwrap(res)
305
- raise WorkflowError, res[:error] if res[:error]
306
-
307
- res
308
- end
309
- end
310
-
311
197
  class WorkflowDefinition < CallableDefinition
312
198
  def callable_kind
313
199
  :workflow
@@ -359,7 +245,7 @@ module Browserctl
359
245
  last_error = nil
360
246
  (defn.retry_count + 1).times do
361
247
  execute_step_block(ctx, defn)
362
- return StepResult.new(name: defn.label, ok: true)
248
+ return StepResult.new(name: defn.label, ok: true, error: nil)
363
249
  rescue StandardError => e
364
250
  last_error = e
365
251
  end
data/lib/browserctl.rb CHANGED
@@ -4,16 +4,42 @@ require_relative "browserctl/version"
4
4
  require_relative "browserctl/constants"
5
5
  require_relative "browserctl/errors"
6
6
  require_relative "browserctl/secret_resolvers"
7
+ require_relative "browserctl/tracing"
7
8
  require_relative "browserctl/workflow"
8
9
  require_relative "browserctl/runner"
9
10
  require_relative "browserctl/client"
10
11
 
11
12
  module Browserctl
13
+ # Default per-plugin command timeout (seconds). Plugins registered via
14
+ # {register_command} without an explicit `timeout:` are wrapped in this
15
+ # cap by `Browserctl::PluginDispatcher`. Pass `timeout: nil` on
16
+ # `register_command` to opt out (not recommended — a runaway plugin will
17
+ # hold the daemon's command thread until the process is restarted).
18
+ DEFAULT_PLUGIN_TIMEOUT = 30
19
+
20
+ # Value object carried by the plugin registry. `block` is the plugin's
21
+ # `(session, req) -> response` callable; `timeout` is the per-command wall
22
+ # clock in seconds (or `nil` for no timeout).
23
+ PluginCommand = Struct.new(:block, :timeout, keyword_init: true)
24
+
12
25
  @plugin_commands_mutex = Mutex.new
13
26
  @plugin_commands = {}
14
27
 
15
- def self.register_command(name, &block)
16
- @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] = block }
28
+ # Registers a plugin command callable under `name`. The block receives
29
+ # `(session, req)` and must return a JSON-RPC-shaped Hash. The daemon wraps
30
+ # every invocation in a per-plugin timeout (default
31
+ # {DEFAULT_PLUGIN_TIMEOUT}) and a rescue boundary that converts any
32
+ # uncaught exception into a typed `PLUGIN_FAILED` response without taking
33
+ # down the daemon — see {Browserctl::PluginDispatcher}.
34
+ #
35
+ # @param name [String, Symbol] command verb on the wire
36
+ # @param timeout [Numeric, nil] per-invocation timeout in seconds;
37
+ # `nil` opts out of the timeout entirely. Defaults to
38
+ # {DEFAULT_PLUGIN_TIMEOUT}.
39
+ def self.register_command(name, timeout: DEFAULT_PLUGIN_TIMEOUT, &block)
40
+ @plugin_commands_mutex.synchronize do
41
+ @plugin_commands[name.to_s] = PluginCommand.new(block: block, timeout: timeout)
42
+ end
17
43
  end
18
44
 
19
45
  def self.lookup_plugin_command(name)