browserctl 0.10.0 → 0.12.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. metadata +44 -7
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Replay
5
+ # Per-page replay context carried by PageProxy during a workflow run
6
+ # generated from a recording.
7
+ #
8
+ # Holds the recorded fingerprint for each selector that the workflow
9
+ # interacts with. When a selector-driven command fails with
10
+ # selector_not_found at replay time, the proxy looks up the fingerprint
11
+ # here and asks FingerprintMatcher to find a candidate in the live
12
+ # snapshot. The matched element's stable ref is then re-used to retry
13
+ # the original command.
14
+ #
15
+ # Drift events (rematches, threshold misses) are accumulated on the
16
+ # context so the surrounding workflow runner can render them into a
17
+ # drift report at end-of-run.
18
+ class Context
19
+ DriftEvent = Struct.new(:command, :selector, :matched_ref, :score, :reason, keyword_init: true)
20
+
21
+ attr_reader :drift_events
22
+
23
+ def initialize(fingerprints: {})
24
+ @fingerprints = fingerprints
25
+ @drift_events = []
26
+ end
27
+
28
+ def fingerprint_for(selector)
29
+ @fingerprints[selector]
30
+ end
31
+
32
+ def record(command:, selector:, matched_ref: nil, score: nil, reason: nil)
33
+ @drift_events << DriftEvent.new(
34
+ command: command, selector: selector,
35
+ matched_ref: matched_ref, score: score, reason: reason
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Replay
5
+ # Scores candidate snapshot entries against a recorded fingerprint and
6
+ # returns the best match above a configurable threshold.
7
+ #
8
+ # Inputs are the wire-shape fingerprints emitted by Snapshot::Fingerprint:
9
+ # { text:, role:, neighbors: [...], position: { index:, depth: } }
10
+ #
11
+ # Score is a weighted sum in [0.0, 1.0]:
12
+ # text 0.40 (exact match; case-insensitive)
13
+ # role 0.20 (exact match)
14
+ # neighbors 0.25 (Jaccard over the neighbor sets)
15
+ # position 0.15 (proximity in (index, depth) space)
16
+ #
17
+ # Defaults reflect the v0.11 acceptance bar: text + role together (0.60)
18
+ # are enough to clear the default threshold, so a renamed neighbor or a
19
+ # shifted index doesn't break replay.
20
+ class FingerprintMatcher
21
+ DEFAULT_THRESHOLD = 0.6
22
+ WEIGHTS = { text: 0.40, role: 0.20, neighbors: 0.25, position: 0.15 }.freeze
23
+
24
+ Match = Struct.new(:candidate, :score, keyword_init: true)
25
+
26
+ def initialize(threshold: DEFAULT_THRESHOLD, weights: WEIGHTS)
27
+ @threshold = threshold
28
+ @weights = weights
29
+ end
30
+
31
+ # Returns the highest-scoring candidate entry above the threshold, or
32
+ # nil if no candidate qualifies. `candidates` must be an array of
33
+ # snapshot entries (hashes with a :fingerprint key). The returned
34
+ # Match wraps the candidate hash and the numeric score.
35
+ def best(target_fp, candidates)
36
+ scored = candidates
37
+ .map { |c| Match.new(candidate: c, score: score(target_fp, c[:fingerprint])) }
38
+ .sort_by { |m| -m.score }
39
+
40
+ winner = scored.first
41
+ return nil unless winner && winner.score >= @threshold
42
+
43
+ winner
44
+ end
45
+
46
+ def score(target, candidate)
47
+ return 0.0 unless target && candidate
48
+
49
+ (@weights[:text] * text_score(target[:text], candidate[:text])) +
50
+ (@weights[:role] * bool_score(target[:role] == candidate[:role])) +
51
+ (@weights[:neighbors] * jaccard(target[:neighbors], candidate[:neighbors])) +
52
+ (@weights[:position] * position_score(target[:position], candidate[:position]))
53
+ end
54
+
55
+ private
56
+
57
+ def text_score(target, candidate)
58
+ return 0.0 if target.nil? || candidate.nil? || target.empty? || candidate.empty?
59
+
60
+ target.downcase.strip == candidate.downcase.strip ? 1.0 : 0.0
61
+ end
62
+
63
+ def bool_score(flag) = flag ? 1.0 : 0.0
64
+
65
+ def jaccard(target, candidate)
66
+ target = Array(target)
67
+ candidate = Array(candidate)
68
+ return 1.0 if target.empty? && candidate.empty?
69
+ return 0.0 if target.empty? || candidate.empty?
70
+
71
+ inter = (target & candidate).size
72
+ union = (target | candidate).size
73
+ union.zero? ? 0.0 : inter.to_f / union
74
+ end
75
+
76
+ def position_score(target, candidate)
77
+ return 0.0 unless target && candidate
78
+
79
+ idx_d = (target[:index].to_i - candidate[:index].to_i).abs
80
+ depth_d = (target[:depth].to_i - candidate[:depth].to_i).abs
81
+ # Soft falloff: 1.0 when identical, ~0 once they're 4+ apart in either axis.
82
+ [1.0 - ((idx_d + depth_d) / 8.0), 0.0].max
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Browserctl
6
+ module Replay
7
+ # Stable digest + element-set comparison for post-step snapshots.
8
+ #
9
+ # The digest is intentionally cheap and stable across cosmetic DOM noise:
10
+ # only the (selector, role, tag) triples drive the hash, sorted to remove
11
+ # ordering effects. That's enough to flag structural drift (a step that
12
+ # used to land on /dashboard now lands on /login) without flapping on
13
+ # every reflow or class rename.
14
+ module SnapshotDiff
15
+ module_function
16
+
17
+ def digest(snapshot)
18
+ return nil if snapshot.nil?
19
+
20
+ keys = Array(snapshot).map { |el| identity_tuple(el) }.compact.sort
21
+ Digest::SHA1.hexdigest(keys.join("\n"))[0, 16]
22
+ end
23
+
24
+ # Returns { added: [...], removed: [...] } of element selectors that
25
+ # differ between two snapshots. Empty arrays mean structurally identical.
26
+ def compare(prev, current)
27
+ prev_set = element_set(prev)
28
+ current_set = element_set(current)
29
+ {
30
+ added: (current_set - prev_set).sort,
31
+ removed: (prev_set - current_set).sort
32
+ }
33
+ end
34
+
35
+ def identity_tuple(entry)
36
+ return nil unless entry.is_a?(Hash)
37
+
38
+ sel = entry[:selector] || entry["selector"]
39
+ role = entry[:role] || entry["role"]
40
+ tag = entry[:tag] || entry["tag"]
41
+ return nil unless sel
42
+
43
+ "#{sel}|#{role}|#{tag}"
44
+ end
45
+
46
+ def element_set(snapshot)
47
+ Array(snapshot).map { |entry| entry[:selector] || entry["selector"] }.compact
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../constants"
6
+
7
+ module Browserctl
8
+ module Replay
9
+ # Append-only JSONL log of replay drift events for offline analysis.
10
+ # Local-only; nothing is uploaded. One line per event.
11
+ module Telemetry
12
+ LOG_BASENAME = "replay_drift.jsonl"
13
+
14
+ module_function
15
+
16
+ def log_path
17
+ File.join(Browserctl::BROWSERCTL_DIR, LOG_BASENAME)
18
+ end
19
+
20
+ # Write each drift event from a Replay::Context as its own JSONL line.
21
+ # @param ctx [Browserctl::Replay::Context, nil]
22
+ # @param workflow [String] workflow name for cross-reference
23
+ # @param path [String] override the destination (testing)
24
+ # @return [Integer] number of events written
25
+ def emit(ctx, workflow:, path: log_path)
26
+ events = ctx&.drift_events
27
+ return 0 if events.nil? || events.empty?
28
+
29
+ ensure_log_file(path)
30
+ ts = Time.now.utc.iso8601
31
+ File.open(path, "a") do |f|
32
+ events.each do |e|
33
+ f.puts JSON.generate(
34
+ event: "replay_drift",
35
+ ts: ts,
36
+ workflow: workflow,
37
+ command: e.command.to_s,
38
+ selector: e.selector,
39
+ matched_ref: e.matched_ref,
40
+ score: e.score,
41
+ reason: e.reason
42
+ )
43
+ end
44
+ end
45
+ events.size
46
+ rescue SystemCallError, IOError
47
+ # Telemetry must never break a run.
48
+ 0
49
+ end
50
+
51
+ def ensure_log_file(path)
52
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
53
+ return if File.exist?(path)
54
+
55
+ FileUtils.touch(path)
56
+ File.chmod(0o600, path)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Browserctl
8
+ # Enforces that any explicit `code:` keyword passed to a `raise` of a
9
+ # `Browserctl::*` error refers to a constant from
10
+ # `Browserctl::Error::Codes` rather than a free-form string literal.
11
+ #
12
+ # Subclasses with their own `default_code` are trusted (the cop does not
13
+ # try to statically resolve `default_code` across files); the contract
14
+ # is enforced by the unit test suite. This cop's job is to catch the
15
+ # specific failure mode of inlining a stale snake_case code at the
16
+ # raise site and bypassing the canonical enum.
17
+ #
18
+ # @example
19
+ # # bad — string literal that isn't a Codes constant
20
+ # raise Browserctl::Error, "state expired", code: "state_expired"
21
+ #
22
+ # # good — Codes constant reference
23
+ # raise Browserctl::Error, "state expired",
24
+ # code: Browserctl::Error::Codes::STATE_EXPIRED
25
+ #
26
+ # # good — typed subclass relies on its own default_code
27
+ # raise Browserctl::SelectorNotFound, "no such selector"
28
+ #
29
+ # The pattern is intentionally narrow. The full default_code-vs-Codes
30
+ # reconciliation lives in `lib/browserctl/errors.rb` and is covered by
31
+ # `spec/unit/errors_spec.rb`.
32
+ class TypedError < RuboCop::Cop::Base
33
+ MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
34
+ "got string literal %<value>p. See lib/browserctl/error/codes.rb."
35
+
36
+ VALID_CODES = %w[
37
+ AUTH_REQUIRED
38
+ SELECTOR_NOT_FOUND
39
+ STATE_EXPIRED
40
+ SECRET_RESOLUTION_FAILED
41
+ DAEMON_UNREACHABLE
42
+ PROTOCOL_MISMATCH
43
+ ].freeze
44
+
45
+ # Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
46
+ # `code:` value node.
47
+ def_node_matcher :browserctl_raise_with_code, <<~PATTERN
48
+ (send nil? :raise
49
+ (const (const nil? :Browserctl) _)
50
+ ...
51
+ (hash <(pair (sym :code) $_) ...>))
52
+ PATTERN
53
+
54
+ def on_send(node)
55
+ return unless node.method?(:raise)
56
+
57
+ browserctl_raise_with_code(node) do |code_value|
58
+ next unless code_value.str_type?
59
+
60
+ value = code_value.value
61
+ next if VALID_CODES.include?(value)
62
+
63
+ add_offense(code_value, message: format(MSG, value: value))
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "workflow"
5
+ require_relative "workflow/promotion_ledger"
5
6
  require_relative "client"
7
+ require_relative "replay/telemetry"
6
8
 
7
9
  module Browserctl
8
10
  class Runner
@@ -14,13 +16,27 @@ module Browserctl
14
16
  # Runs a named workflow with the given parameters.
15
17
  # @param name [String] workflow name (must match /\A[a-zA-Z0-9_-]+\z/)
16
18
  # @param params [Hash] keyword arguments passed to the workflow
17
- # @return [Boolean] true if all steps succeeded
19
+ # @param check [Boolean] when true, attaches a Replay::Context, renders
20
+ # a drift report after the run, and signals drift via exit code 2.
21
+ # @return [Symbol] :clean (all ok, no drift), :drift (all ok, drift seen), :fail (any step failed)
18
22
  # @raise [WorkflowError] if the name is invalid or a step fails
19
- def run_workflow(name, **params)
23
+ def run_workflow(name, check: false, **params)
20
24
  defn = fetch_workflow(name)
21
- results = defn.call(params, Client.new)
25
+ ctx = check ? Browserctl::Replay::Context.new : nil
26
+ begin
27
+ results = defn.call(params, Client.new, replay_context: ctx)
28
+ rescue StandardError
29
+ Browserctl::Workflow::PromotionLedger.record(workflow: name.to_s, verdict: :fail) if check
30
+ raise
31
+ end
22
32
  print_results(results)
23
- results.all?(&:ok)
33
+ v = verdict(results, ctx)
34
+ if check
35
+ print_drift_report(ctx)
36
+ Browserctl::Replay::Telemetry.emit(ctx, workflow: name.to_s)
37
+ Browserctl::Workflow::PromotionLedger.record(workflow: name.to_s, verdict: v)
38
+ end
39
+ v
24
40
  end
25
41
 
26
42
  # Lists all registered workflows from the standard search paths.
@@ -41,7 +57,7 @@ module Browserctl
41
57
  SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
42
58
 
43
59
  def self.load_params_file(path)
44
- raise "params file not found: #{path}" unless File.exist?(path)
60
+ raise Browserctl::WorkflowError, "params file not found: #{path}" unless File.exist?(path)
45
61
 
46
62
  case File.extname(path).downcase
47
63
  when ".yml", ".yaml"
@@ -50,12 +66,12 @@ module Browserctl
50
66
  when ".json"
51
67
  JSON.parse(File.read(path), symbolize_names: true)
52
68
  else
53
- raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
69
+ raise Browserctl::WorkflowError, "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
54
70
  end
55
71
  rescue Psych::SyntaxError => e
56
- raise "invalid YAML in #{path}: #{e.message}"
72
+ raise Browserctl::WorkflowError, "invalid YAML in #{path}: #{e.message}"
57
73
  rescue JSON::ParserError => e
58
- raise "invalid JSON in #{path}: #{e.message}"
74
+ raise Browserctl::WorkflowError, "invalid JSON in #{path}: #{e.message}"
59
75
  end
60
76
 
61
77
  private
@@ -79,7 +95,10 @@ module Browserctl
79
95
  return if Browserctl.lookup_workflow(name.to_s)
80
96
 
81
97
  path = workflow_path(name)
82
- load path if path
98
+ return unless path
99
+
100
+ Browserctl.verify_workflow_format_version!(path)
101
+ load path
83
102
  end
84
103
 
85
104
  def workflow_path(name)
@@ -95,7 +114,10 @@ module Browserctl
95
114
 
96
115
  def load_from_dir(dir)
97
116
  Dir.glob("#{dir}/*.rb").each do |f|
98
- load f unless $LOADED_FEATURES.include?(f)
117
+ next if $LOADED_FEATURES.include?(f)
118
+
119
+ Browserctl.verify_workflow_format_version!(f)
120
+ load f
99
121
  end
100
122
  end
101
123
 
@@ -109,6 +131,24 @@ module Browserctl
109
131
  $stdout.puts " #{label} #{msg}"
110
132
  end
111
133
 
134
+ def print_drift_report(ctx)
135
+ events = ctx&.drift_events || []
136
+ report = {
137
+ drift: events.any?,
138
+ rematches: events.count { |e| e.reason == "rematch" },
139
+ unresolved: events.count { |e| e.reason == "no candidate above threshold" },
140
+ events: events.map(&:to_h)
141
+ }
142
+ $stdout.puts JSON.pretty_generate(report)
143
+ end
144
+
145
+ def verdict(results, ctx)
146
+ return :fail unless results.all?(&:ok)
147
+ return :drift if ctx&.drift_events&.any?
148
+
149
+ :clean
150
+ end
151
+
112
152
  def format_params(defn)
113
153
  defn.param_defs.transform_values do |p|
114
154
  entry = { required: p.required, secret: p.secret, default: p.default }
@@ -4,8 +4,9 @@ require_relative "errors"
4
4
 
5
5
  module Browserctl
6
6
  class SecretResolverRegistry
7
- @mutex = Mutex.new
8
- @registry = {}
7
+ @mutex = Mutex.new
8
+ @registry = {}
9
+ @resolved_values = []
9
10
 
10
11
  def self.register(resolver_class)
11
12
  instance = resolver_class.new
@@ -25,7 +26,9 @@ module Browserctl
25
26
  raise SecretResolverError, msg
26
27
  end
27
28
 
28
- resolver.resolve(reference)
29
+ value = resolver.resolve(reference)
30
+ record_resolved_value(value)
31
+ value
29
32
  rescue SecretResolverError
30
33
  raise
31
34
  rescue StandardError => e
@@ -36,8 +39,24 @@ module Browserctl
36
39
  @mutex.synchronize { @registry.key?(scheme) }
37
40
  end
38
41
 
42
+ # In-memory record of values resolved during this process. Used by the
43
+ # Redactor so trace output never leaks values that flowed through the
44
+ # registry. Never persisted.
45
+ def self.resolved_values
46
+ @mutex.synchronize { @resolved_values.dup }
47
+ end
48
+
49
+ def self.record_resolved_value(value)
50
+ return unless value.is_a?(String) && !value.empty?
51
+
52
+ @mutex.synchronize { @resolved_values << value unless @resolved_values.include?(value) }
53
+ end
54
+
39
55
  def self.reset!
40
- @mutex.synchronize { @registry.clear }
56
+ @mutex.synchronize do
57
+ @registry.clear
58
+ @resolved_values.clear
59
+ end
41
60
  end
42
61
  end
43
62
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "snapshot_builder"
4
4
  require_relative "page_session"
5
+ require_relative "handlers/error_payload"
5
6
  require_relative "handlers/page_lifecycle"
6
7
  require_relative "handlers/navigation"
7
8
  require_relative "handlers/observation"
@@ -11,12 +12,16 @@ require_relative "handlers/devtools"
11
12
  require_relative "handlers/daemon_control"
12
13
  require_relative "handlers/storage"
13
14
  require_relative "handlers/session"
15
+ require_relative "handlers/state"
14
16
  require_relative "handlers/interaction"
15
17
  require_relative "../detectors"
16
18
  require_relative "../policy"
19
+ require_relative "../errors"
20
+ require_relative "../replay/snapshot_diff"
17
21
 
18
22
  module Browserctl
19
23
  class CommandDispatcher
24
+ include Handlers::ErrorPayload
20
25
  include Handlers::PageLifecycle
21
26
  include Handlers::Navigation
22
27
  include Handlers::Observation
@@ -26,6 +31,7 @@ module Browserctl
26
31
  include Handlers::DaemonControl
27
32
  include Handlers::Storage
28
33
  include Handlers::Session
34
+ include Handlers::State
29
35
  include Handlers::Interaction
30
36
 
31
37
  COMMAND_MAP = {
@@ -36,6 +42,7 @@ module Browserctl
36
42
  "navigate" => :cmd_navigate,
37
43
  "wait" => :cmd_wait,
38
44
  "snapshot" => :cmd_snapshot,
45
+ "auth_check" => :cmd_auth_check,
39
46
  "evaluate" => :cmd_evaluate,
40
47
  "fill" => :cmd_fill,
41
48
  "click" => :cmd_click,
@@ -66,7 +73,12 @@ module Browserctl
66
73
  "session_save" => :cmd_session_save,
67
74
  "session_load" => :cmd_session_load,
68
75
  "session_list" => :cmd_session_list,
69
- "session_delete" => :cmd_session_delete
76
+ "session_delete" => :cmd_session_delete,
77
+ "state_save" => :cmd_state_save,
78
+ "state_load" => :cmd_state_load,
79
+ "state_list" => :cmd_state_list,
80
+ "state_info" => :cmd_state_info,
81
+ "state_delete" => :cmd_state_delete
70
82
  }.freeze
71
83
 
72
84
  SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
@@ -21,7 +21,11 @@ module Browserctl
21
21
  def cmd_fetch(req)
22
22
  key = req[:key].to_s
23
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" }
24
+ found || error_payload(
25
+ code: Browserctl::Error::Codes::KEY_NOT_FOUND,
26
+ message: "key '#{key}' not found",
27
+ context: { key: key }
28
+ )
25
29
  end
26
30
  end
27
31
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ # Centralised structured-error builder for daemon JSON-RPC responses.
9
+ # Each handler returns `{ error:, code:, context:, suggested_action: }`
10
+ # for any failure carrying a stable {Browserctl::Error::Codes} code.
11
+ module ErrorPayload
12
+ # @param code [String] a SCREAMING_SNAKE code from {Codes}
13
+ # @param message [String] human-readable error
14
+ # @param context [Hash] free-form structured fields (selector, path, ...)
15
+ # @return [Hash{Symbol => Object}]
16
+ def error_payload(code:, message:, context: {})
17
+ {
18
+ error: message,
19
+ code: code,
20
+ context: context,
21
+ suggested_action: Browserctl::Error::SuggestedActions.for(code)
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -27,7 +27,13 @@ module Browserctl
27
27
  "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
28
28
  "})(#{sel.to_json})"
29
29
  )
30
- return { error: "selector not found: #{sel}" } unless coords
30
+ unless coords
31
+ return error_payload(
32
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
33
+ message: "selector not found: #{sel}",
34
+ context: { selector: sel }
35
+ )
36
+ end
31
37
 
32
38
  session.page.mouse.move(x: coords["x"], y: coords["y"])
33
39
  { ok: true }
@@ -43,7 +49,13 @@ module Browserctl
43
49
  return sel if sel.is_a?(Hash)
44
50
 
45
51
  el = session.page.at_css(sel)
46
- return { error: "selector not found: #{sel}" } unless el
52
+ unless el
53
+ return error_payload(
54
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
55
+ message: "selector not found: #{sel}",
56
+ context: { selector: sel }
57
+ )
58
+ end
47
59
 
48
60
  el.select_file(path)
49
61
  { ok: true }
@@ -56,7 +68,13 @@ module Browserctl
56
68
  return sel if sel.is_a?(Hash)
57
69
 
58
70
  el = session.page.at_css(sel)
59
- return { error: "selector not found: #{sel}" } unless el
71
+ unless el
72
+ return error_payload(
73
+ code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
74
+ message: "selector not found: #{sel}",
75
+ context: { selector: sel }
76
+ )
77
+ end
60
78
 
61
79
  el.evaluate(
62
80
  "this.value = #{req[:value].to_json}; " \