browserctl 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a09c4e2ffdede17be7f5d14108d72ef30ea75c82bbae924b753474e77c5778c2
4
- data.tar.gz: 27dbcdbf87b1e2460ebfc7913dbeecf9780e218f7824e5e9292668db5a0fb59e
3
+ metadata.gz: '08df95ac04f1480c4e39d857c1472342b8b256a2047cb83808ba62daaf184330'
4
+ data.tar.gz: 1f67c6b991dd20af3907ab9a803f1021817e6d9b5dbe1774e42f0c509d907067
5
5
  SHA512:
6
- metadata.gz: 4bb8d31d716febfc0a3ffa593d7d1141dfdf1ad6d548d5d2b8e4ed53a8d04adb9f5861728e3864f65b7a296cc58c93c2e3d66b4f632988508fc699cb841837d0
7
- data.tar.gz: 8791f86942d5447fec3dc1aec3391c106a8553f7ef9e99ab372750a7ac18a278e824940a09cca5492bdc8a87e90dcd09ad7e1d04a47c9258e4783c2573bc36e6
6
+ metadata.gz: 5752121da2c9a9551773b6ab8b9c4f060744f14a6f505d7c38ab9338a99b2cda4012a22869b633be883ad1c810bb717e0edb77da8ef4eb3b7bf8eef19593efde
7
+ data.tar.gz: 4c44a6cfbb1b4402e30e1911c26bdfb608d3584ba463623fd8c75638ad836dcb428fcb0d99d58d37c2e557b0752c84f288288bad800667e18300cb36ace2570c
data/CHANGELOG.md CHANGED
@@ -10,6 +10,13 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.14.0](https://github.com/patrick204nqh/browserctl/compare/v0.13.1...v0.14.0) (2026-05-11)
14
+
15
+
16
+ ### Features
17
+
18
+ * validation error codes (v0.14 WS-1 PR 1) ([#179](https://github.com/patrick204nqh/browserctl/issues/179)) ([5475272](https://github.com/patrick204nqh/browserctl/commit/547527264c606e70d0c5509b58a42a10a7a93470))
19
+
13
20
  ## [0.13.1](https://github.com/patrick204nqh/browserctl/compare/v0.13.0...v0.13.1) (2026-05-11)
14
21
 
15
22
 
@@ -48,7 +48,13 @@ module Browserctl
48
48
  end
49
49
 
50
50
  def step(label, retry_count: 0, timeout: nil, &block)
51
- raise ArgumentError, "#{callable_kind} step '#{label}' requires a block" unless block
51
+ unless block
52
+ raise Browserctl::Error.new(
53
+ "#{callable_kind} step '#{label}' requires a block",
54
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
55
+ context: { dsl: callable_kind, action: :step, label: label }
56
+ )
57
+ end
52
58
 
53
59
  @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
54
60
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../recording"
4
+
5
+ module Browserctl
6
+ class Client
7
+ # Bridges Client#call to the Recording subsystem. Keeps Client itself a
8
+ # pure IPC shim — Recording is pluggable via constructor injection.
9
+ class RecordingInterceptor
10
+ def initialize(recording: Browserctl::Recording)
11
+ @recording = recording
12
+ end
13
+
14
+ # Whether recording is currently active. Call sites that need to vary
15
+ # their request shape (e.g. click/fill passing capture_post_snapshot:)
16
+ # can ask without touching Recording directly.
17
+ def active?
18
+ @recording.active
19
+ end
20
+
21
+ # Returns `true` when active, `nil` otherwise. Matches the shape that
22
+ # click/fill historically passed as the `capture_post_snapshot` param.
23
+ def capture_post_snapshot_flag
24
+ return true if active?
25
+
26
+ nil
27
+ end
28
+
29
+ # Called after a successful Client#call to append the command and
30
+ # response to the active recording log. No-op if the response was
31
+ # not ok (recording only captures successful interactions).
32
+ def append(cmd, response:, params: {})
33
+ return unless response[:ok]
34
+
35
+ @recording.append(cmd, response: response, **params)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -4,18 +4,19 @@ require "fileutils"
4
4
  require "socket"
5
5
  require "json"
6
6
  require_relative "constants"
7
- require_relative "recording"
7
+ require_relative "client/recording_interceptor"
8
8
 
9
9
  module Browserctl
10
10
  # Thin IPC client that wraps each browserd command as a Ruby method call.
11
11
  class Client
12
- def initialize(socket_path = nil)
12
+ def initialize(socket_path = nil, recording_interceptor: nil)
13
13
  @socket_path = socket_path || auto_discover_socket
14
+ @recording_interceptor = recording_interceptor
14
15
  end
15
16
 
16
17
  def call(cmd, **params)
17
18
  result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
18
- Recording.append(cmd, response: result, **params) if result[:ok]
19
+ recording_interceptor.append(cmd, response: result, params: params)
19
20
  result
20
21
  rescue Errno::ENOENT, Errno::ECONNREFUSED
21
22
  raise DaemonUnavailableError, "browserd is not running — start it with: browserd"
@@ -48,10 +49,16 @@ module Browserctl
48
49
  # @param ref [String, nil] snapshot ref (e.g. "e3")
49
50
  # @return [Hash] `{ ok: true }` or `{ error: }`
50
51
  def click(name, selector = nil, ref: nil)
51
- raise ArgumentError, "click: provide selector or ref:" unless selector || ref
52
+ unless selector || ref
53
+ raise Browserctl::Error.new(
54
+ "click: provide selector or ref",
55
+ code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
56
+ context: { method: :click, name: name }
57
+ )
58
+ end
52
59
 
53
60
  call("click", name: name, selector: selector, ref: ref,
54
- capture_post_snapshot: Recording.active ? true : nil)
61
+ capture_post_snapshot: recording_interceptor.capture_post_snapshot_flag)
55
62
  end
56
63
 
57
64
  # Fills an input element with a value.
@@ -61,10 +68,16 @@ module Browserctl
61
68
  # @param ref [String, nil] snapshot ref
62
69
  # @return [Hash] `{ ok: true }` or `{ error: }`
63
70
  def fill(name, selector = nil, value = nil, ref: nil)
64
- raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
71
+ unless selector || ref
72
+ raise Browserctl::Error.new(
73
+ "fill: provide selector or ref",
74
+ code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
75
+ context: { method: :fill, name: name }
76
+ )
77
+ end
65
78
 
66
79
  call("fill", name: name, selector: selector, ref: ref, value: value,
67
- capture_post_snapshot: Recording.active ? true : nil)
80
+ capture_post_snapshot: recording_interceptor.capture_post_snapshot_flag)
68
81
  end
69
82
 
70
83
  # Takes a screenshot of a named page.
@@ -243,7 +256,13 @@ module Browserctl
243
256
  # @param selector [String] CSS selector
244
257
  # @return [Hash] `{ ok: true }` or `{ error: }`
245
258
  def hover(name, selector = nil, ref: nil)
246
- raise ArgumentError, "hover: provide selector or ref:" unless selector || ref
259
+ unless selector || ref
260
+ raise Browserctl::Error.new(
261
+ "hover: provide selector or ref",
262
+ code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
263
+ context: { method: :hover, name: name }
264
+ )
265
+ end
247
266
 
248
267
  call("hover", name: name, selector: selector, ref: ref)
249
268
  end
@@ -255,7 +274,13 @@ module Browserctl
255
274
  # @param ref [String, nil] element ref from a prior snapshot
256
275
  # @return [Hash] `{ ok: true }` or `{ error: }`
257
276
  def upload(name, selector = nil, path = nil, ref: nil)
258
- raise ArgumentError, "upload: provide selector or ref:" unless selector || ref
277
+ unless selector || ref
278
+ raise Browserctl::Error.new(
279
+ "upload: provide selector or ref",
280
+ code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
281
+ context: { method: :upload, name: name }
282
+ )
283
+ end
259
284
 
260
285
  call("upload", name: name, selector: selector, ref: ref, path: path)
261
286
  end
@@ -267,7 +292,13 @@ module Browserctl
267
292
  # @param ref [String, nil] element ref from a prior snapshot
268
293
  # @return [Hash] `{ ok: true }` or `{ error: }`
269
294
  def select(name, selector = nil, value = nil, ref: nil)
270
- raise ArgumentError, "select: provide selector or ref:" unless selector || ref
295
+ unless selector || ref
296
+ raise Browserctl::Error.new(
297
+ "select: provide selector or ref",
298
+ code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
299
+ context: { method: :select, name: name }
300
+ )
301
+ end
271
302
 
272
303
  call("select", name: name, selector: selector, ref: ref, value: value)
273
304
  end
@@ -327,6 +358,10 @@ module Browserctl
327
358
 
328
359
  private
329
360
 
361
+ def recording_interceptor
362
+ @recording_interceptor ||= RecordingInterceptor.new
363
+ end
364
+
330
365
  def auto_discover_socket
331
366
  default = Browserctl.socket_path
332
367
  return default if File.exist?(default)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ # Stdin/stderr passphrase prompting for `browserctl state` commands.
8
+ # Honours `BROWSERCTL_STATE_PASSPHRASE` for non-interactive use; otherwise
9
+ # reads from a tty with echo disabled and optional confirmation.
10
+ module PassphrasePrompt
11
+ module_function
12
+
13
+ # @return [String]
14
+ def read(confirm: false)
15
+ return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
16
+
17
+ pass = ask("Passphrase: ")
18
+ if confirm
19
+ confirm_pass = ask("Confirm passphrase: ")
20
+ abort "Passphrases do not match." unless pass == confirm_pass
21
+ end
22
+ pass
23
+ end
24
+
25
+ # Peek at the manifest first so we only prompt when the bundle is
26
+ # actually encrypted.
27
+ def needed_for?(client, name)
28
+ info = client.state_info(name)
29
+ return false if info[:error] || info["error"]
30
+
31
+ manifest = info[:info] || info["info"] || {}
32
+ manifest[:encrypted] || manifest["encrypted"] || false
33
+ end
34
+
35
+ def ask(label)
36
+ $stderr.print(label)
37
+ value = $stdin.noecho(&:gets).to_s.chomp
38
+ $stderr.puts
39
+ value
40
+ end
41
+ private_class_method :ask
42
+ end
43
+ end
44
+ end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "io/console"
4
3
  require "json"
5
4
  require_relative "cli_output"
6
5
  require_relative "output_format"
6
+ require_relative "passphrase_prompt"
7
7
 
8
8
  module Browserctl
9
9
  module Commands
@@ -23,7 +23,6 @@ module Browserctl
23
23
 
24
24
  def self.run(client, args)
25
25
  sub = args.shift or abort USAGE
26
-
27
26
  if (m = DAEMON_SUBCOMMANDS[sub])
28
27
  sub == "list" ? send(m, client) : send(m, client, args)
29
28
  elsif (m = LOCAL_SUBCOMMANDS[sub])
@@ -39,22 +38,14 @@ module Browserctl
39
38
  flow = extract_value!(args, "--flow")
40
39
  name = args.shift or abort "usage: browserctl state save <name> [--encrypt] " \
41
40
  "[--origins a,b] [--flow NAME]"
42
-
43
- passphrase = encrypt ? prompt_passphrase(confirm: true) : nil
44
- origin_list = parse_origins(origins)
45
-
41
+ passphrase = encrypt ? PassphrasePrompt.read(confirm: true) : nil
42
+ origin_list = origins ? origins.split(",").map(&:strip).reject(&:empty?) : nil
46
43
  print_result(client.state_save(name, origins: origin_list, flow: flow, passphrase: passphrase))
47
44
  end
48
45
 
49
- def self.parse_origins(value)
50
- return nil unless value
51
-
52
- value.split(",").map(&:strip).reject(&:empty?)
53
- end
54
-
55
46
  def self.run_load(client, args)
56
47
  name = args.shift or abort "usage: browserctl state load <name>"
57
- passphrase = state_needs_passphrase?(client, name) ? prompt_passphrase : nil
48
+ passphrase = PassphrasePrompt.needed_for?(client, name) ? PassphrasePrompt.read : nil
58
49
  print_result(client.state_load(name, passphrase: passphrase))
59
50
  end
60
51
 
@@ -72,67 +63,33 @@ module Browserctl
72
63
  print_result(client.state_delete(name))
73
64
  end
74
65
 
75
- # Re-runs the flow bound to <name> and re-saves the bundle. The flow is
76
- # read from the manifest (set when the bundle was originally produced
77
- # via `state save --flow ...`). Params come from --params or k=v pairs.
66
+ # Re-runs the flow bound to <name> and re-saves it. Mutation logic
67
+ # lives in {Browserctl::State::Mutator}; this method is CLI plumbing.
78
68
  def self.run_rotate(client, args)
79
- require "browserctl/flow_registry"
69
+ require "browserctl/state/mutator"
70
+ require "browserctl/runner"
80
71
  page_name = extract_value!(args, "--page")
81
72
  params_path = extract_value!(args, "--params")
82
73
  name = args.shift or abort "usage: browserctl state rotate <name> " \
83
74
  "[--page NAME] [--params FILE] [--key value ...]"
84
75
 
85
- manifest = read_manifest!(client, name)
86
- flow = resolve_bound_flow!(manifest)
87
- params = build_rotate_params(params_path, args)
88
- page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
89
-
90
- flow.run(page: page_proxy, client: client, **params)
76
+ file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
77
+ cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
78
+ page = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
91
79
 
92
- save_result = client.state_save(name,
93
- flow: flow.name,
94
- flow_version: flow.version_string,
95
- origins: manifest[:origins])
96
- print_result(save_result.merge(rotated_flow: flow.name))
80
+ result = Browserctl::State::Mutator.new(client: client)
81
+ .rotate(name: name, params: file_params.merge(cli_params), page: page)
82
+ print_result(result.to_h)
97
83
  rescue Browserctl::FlowError => e
98
84
  warn "Error: #{e.message}"
99
85
  exit 1
100
86
  end
101
87
 
102
- def self.read_manifest!(client, name)
103
- info = client.state_info(name)
104
- abort "Error: #{info[:error] || info['error']}" if info[:error] || info["error"]
105
-
106
- info[:info] || info["info"] || {}
107
- end
108
- private_class_method :read_manifest!
109
-
110
- def self.resolve_bound_flow!(manifest)
111
- flow_name = manifest[:flow] || manifest["flow"]
112
- abort "Error: state has no bound flow — re-save with `state save --flow NAME` first" if flow_name.nil? ||
113
- flow_name.to_s.empty?
114
-
115
- flow = Browserctl::FlowRegistry.resolve(flow_name)
116
- abort "Error: flow '#{flow_name}' not found in registry" unless flow
117
-
118
- flow
119
- end
120
- private_class_method :resolve_bound_flow!
121
-
122
- def self.build_rotate_params(params_path, args)
123
- require "browserctl/runner"
124
- file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
125
- cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
126
- file_params.merge(cli_params)
127
- end
128
- private_class_method :build_rotate_params
129
-
130
88
  def self.run_export(args)
131
89
  name = args.shift or abort "usage: browserctl state export <name> <destination>"
132
90
  destination = args.shift or abort "usage: browserctl state export <name> <destination>"
133
91
  require "browserctl/state"
134
- result = Browserctl::State.export(name, destination)
135
- OutputFormat.current.emit(result)
92
+ OutputFormat.current.emit(Browserctl::State.export(name, destination))
136
93
  rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
137
94
  warn "Error: #{e.message}"
138
95
  exit 1
@@ -142,53 +99,19 @@ module Browserctl
142
99
  name_override = extract_value!(args, "--name")
143
100
  source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
144
101
  require "browserctl/state"
145
- result = Browserctl::State.import(source, name: name_override)
146
- OutputFormat.current.emit(result)
147
- rescue Browserctl::State::Transport::TransportError,
148
- Browserctl::State::Bundle::BundleError,
102
+ OutputFormat.current.emit(Browserctl::State.import(source, name: name_override))
103
+ rescue Browserctl::State::Transport::TransportError, Browserctl::State::Bundle::BundleError,
149
104
  Browserctl::Error, ArgumentError => e
150
105
  warn "Error: #{e.message}"
151
106
  exit 1
152
107
  end
153
108
 
154
- private_class_method :parse_origins
155
-
156
109
  def self.extract_value!(args, flag)
157
- idx = args.index(flag)
158
- return nil unless idx
159
-
110
+ idx = args.index(flag) or return nil
160
111
  args.delete_at(idx)
161
112
  args.delete_at(idx) or abort "missing value for #{flag}"
162
113
  end
163
114
  private_class_method :extract_value!
164
-
165
- def self.prompt_passphrase(confirm: false)
166
- return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
167
-
168
- $stderr.print "Passphrase: "
169
- pass = $stdin.noecho(&:gets).to_s.chomp
170
- $stderr.puts
171
-
172
- if confirm
173
- $stderr.print "Confirm passphrase: "
174
- confirm_pass = $stdin.noecho(&:gets).to_s.chomp
175
- $stderr.puts
176
- abort "Passphrases do not match." unless pass == confirm_pass
177
- end
178
-
179
- pass
180
- end
181
- private_class_method :prompt_passphrase
182
-
183
- # Peek at the manifest first so we only prompt for a passphrase when needed.
184
- def self.state_needs_passphrase?(client, name)
185
- info = client.state_info(name)
186
- return false if info[:error] || info["error"]
187
-
188
- manifest = info[:info] || info["info"] || {}
189
- manifest[:encrypted] || manifest["encrypted"] || false
190
- end
191
- private_class_method :state_needs_passphrase?
192
115
  end
193
116
  end
194
117
  end
@@ -1,112 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "time"
5
4
  require_relative "../logger"
6
5
  require_relative "../redactor"
7
6
  require_relative "../secret_resolver_registry"
7
+ require_relative "../trace/event_stream"
8
+ require_relative "../trace/renderer"
8
9
  require_relative "output_format"
9
10
 
10
11
  module Browserctl
11
12
  module Commands
12
13
  # `browserctl trace [<session>] [--no-redact]` — pretty timeline of
13
- # structured log events across cli.log + daemon.log. Defaults to most
14
- # recent session.
15
- #
16
- # Loose categorisation by inspecting common keys (event/snapshot/request/
17
- # error). No schema is enforced — this command is tolerant of any JSONL
18
- # produced by Browserctl::JsonlFormatter.
19
- #
20
- # Redaction: ON by default. Secret values are sourced from current ENV
21
- # patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
22
- # captured by `SecretResolverRegistry` during this process. Pass
23
- # `--no-redact` to disable (local debugging only). Note: when replaying
24
- # historical traces from a previous process, registry-captured values are
25
- # gone — only current ENV patterns apply.
14
+ # structured log events across cli.log + daemon.log. Thin CLI dispatcher;
15
+ # parsing lives in `Browserctl::Trace::EventStream`, rendering in
16
+ # `Browserctl::Trace::Renderer`, redaction in `Browserctl::Redactor`.
26
17
  module Trace
27
18
  USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
28
19
  NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
29
20
  "do not paste this output publicly."
30
21
 
31
- LEVEL_COLORS = {
32
- "DEBUG" => "\e[2;37m", # dim grey
33
- "INFO" => "\e[36m", # cyan
34
- "WARN" => "\e[33m", # yellow
35
- "ERROR" => "\e[31m" # red
36
- }.freeze
37
- RESET = "\e[0m"
38
-
39
- CATEGORY_ICONS = {
40
- error: "!",
41
- snapshot: "S",
42
- network: "N",
43
- event: "."
44
- }.freeze
45
-
46
- OMIT_KEYS = %w[ts level component event msg].freeze
47
-
48
22
  def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
49
23
  abort USAGE if args.include?("-h") || args.include?("--help")
50
24
  args = args.dup
51
25
  redact = !args.delete("--no-redact")
52
26
  session_filter = args.shift
53
- redactor = resolve_redactor(redact, err)
54
- records = collect_records(log_dir, session_filter, out)
55
- emit_records(records, redactor, out) if records
56
- end
27
+ redactor = redact ? build_redactor : (warn_no_redact(err) || nil)
57
28
 
58
- def self.resolve_redactor(redact, err)
59
- return nil unless redact
29
+ stream = Browserctl::Trace::EventStream.new(log_dir, session_filter: session_filter)
30
+ if stream.empty?
31
+ emit_empty(empty_message(log_dir, session_filter), out)
32
+ return
33
+ end
60
34
 
61
- build_redactor
62
- ensure
63
- warn_no_redact(err) unless redact
35
+ emit(stream, redactor, out)
64
36
  end
65
37
 
66
- def self.collect_records(log_dir, session_filter, out)
67
- records = load_records(log_dir)
68
- if records.empty?
69
- emit_empty("No log entries found in #{log_dir}", out)
70
- return nil
71
- end
72
-
73
- records = filter_session(records, session_filter)
74
- if records.empty?
75
- emit_empty("No entries match session=#{session_filter}", out)
76
- return nil
38
+ def self.emit(stream, redactor, out)
39
+ fmt = OutputFormat.current
40
+ if fmt.json?
41
+ fmt.emit({ records: stream.records.map { |r| redact_record(r, redactor) } }, io: out)
42
+ elsif !fmt.silent?
43
+ Browserctl::Trace::Renderer.new(io: out, redactor: redactor).render(stream)
77
44
  end
45
+ end
78
46
 
79
- records
47
+ def self.empty_message(log_dir, session_filter)
48
+ session_filter ? "No entries match session=#{session_filter}" : "No log entries found in #{log_dir}"
80
49
  end
81
50
 
82
51
  def self.emit_empty(message, out)
83
52
  OutputFormat.current.emit({ records: [], message: message }, message, io: out)
84
53
  end
85
54
 
86
- def self.emit_records(records, redactor, out)
87
- fmt = OutputFormat.current
88
- if fmt.json?
89
- fmt.emit({ records: records.map { |r| redact_record(r, redactor) } }, io: out)
90
- elsif !fmt.silent?
91
- render(records, out: out, redactor: redactor)
92
- end
93
- end
94
-
95
55
  def self.redact_record(record, redactor)
96
56
  return record unless redactor
97
57
 
98
- line = JSON.generate(record)
99
- JSON.parse(redactor.redact(line))
58
+ JSON.parse(redactor.redact(JSON.generate(record)))
100
59
  rescue JSON::ParserError
101
60
  record
102
61
  end
103
62
 
104
63
  def self.build_redactor
105
- extra = if defined?(Browserctl::SecretResolverRegistry)
106
- Browserctl::SecretResolverRegistry.resolved_values
107
- else
108
- []
109
- end
64
+ extra = defined?(Browserctl::SecretResolverRegistry) ? Browserctl::SecretResolverRegistry.resolved_values : []
110
65
  Browserctl::Redactor.from_env(extra: extra)
111
66
  rescue StandardError
112
67
  Browserctl::Redactor.new(secrets: [])
@@ -114,103 +69,8 @@ module Browserctl
114
69
 
115
70
  def self.warn_no_redact(err)
116
71
  err&.puts NO_REDACT_WARNING
117
- end
118
-
119
- def self.load_records(log_dir)
120
- paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
121
- records = paths.flat_map do |path|
122
- File.foreach(path).filter_map { |line| parse_line(line) }
123
- end
124
- records.sort_by { |r| r["ts"].to_s }
125
- end
126
-
127
- def self.parse_line(line)
128
- line = line.strip
129
- return nil if line.empty?
130
-
131
- JSON.parse(line)
132
- rescue JSON::ParserError
133
72
  nil
134
73
  end
135
-
136
- # Session resolution. When session_id is stamped on records (future PR),
137
- # filter/select by it. Otherwise, treat the entire merged stream as one
138
- # session — caller can scope by tailing/rotating logs.
139
- # TODO: stamp session_id on every log line so this scopes correctly.
140
- def self.filter_session(records, session_filter)
141
- if session_filter
142
- records.select { |r| r["session_id"].to_s == session_filter }
143
- else
144
- ids = records.map { |r| r["session_id"] }.compact.uniq
145
- if ids.empty?
146
- records
147
- else
148
- recent = ids.last
149
- records.select { |r| r["session_id"] == recent }
150
- end
151
- end
152
- end
153
-
154
- def self.render(records, out:, redactor: nil)
155
- tty = out.respond_to?(:tty?) && out.tty?
156
- records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
157
- end
158
-
159
- def self.format_line(record, tty:, redactor: nil)
160
- level = (record["level"] || "INFO").to_s
161
- line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
162
- ts: format_ts(record["ts"]),
163
- icon: CATEGORY_ICONS.fetch(categorise(record), "."),
164
- level: level,
165
- comp: (record["component"] || "?").to_s,
166
- label: event_label(record),
167
- ctx: context_snippet(record)).rstrip
168
-
169
- line = redactor.redact(line) if redactor
170
- tty ? colourise(line, level) : line
171
- end
172
-
173
- def self.format_ts(timestamp)
174
- Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
175
- rescue ArgumentError, TypeError
176
- "??:??:??.???"
177
- end
178
-
179
- def self.categorise(record)
180
- return :error if record["level"] == "ERROR" || record["error"]
181
- return :snapshot if record["snapshot"]
182
- return :network if record["request"] || record["response"] || record["url"]
183
-
184
- :event
185
- end
186
-
187
- def self.event_label(record)
188
- (record["event"] || record["snapshot"] || record["request"] ||
189
- record["msg"] || "-").to_s.slice(0, 22)
190
- end
191
-
192
- # Compact "k=v k=v" snippet of remaining structured keys, capped to keep
193
- # the timeline scannable. Skips fields already shown in fixed columns.
194
- def self.context_snippet(record)
195
- pairs = record.except(*OMIT_KEYS)
196
- return "" if pairs.empty?
197
-
198
- pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
199
- end
200
-
201
- def self.format_value(value)
202
- case value
203
- when String then value.length > 40 ? "#{value[0, 37]}..." : value
204
- when Array then "[#{value.length}]"
205
- when Hash then "{#{value.keys.length}}"
206
- else value.to_s
207
- end
208
- end
209
-
210
- def self.colourise(line, level)
211
- colour = LEVEL_COLORS[level] || ""
212
- "#{colour}#{line}#{RESET}"
213
- end
214
74
  end
215
75
  end
216
76
  end
@@ -80,7 +80,11 @@ module Browserctl
80
80
  when "brave"
81
81
  resolve_brave_path
82
82
  else
83
- raise ArgumentError, "Unknown browser: #{@browser.inspect}"
83
+ raise Browserctl::Error.new(
84
+ "Unknown browser: #{@browser.inspect}",
85
+ code: Browserctl::Error::Codes::VALIDATION_FAILED,
86
+ context: { browser: @browser }
87
+ )
84
88
  end
85
89
  end
86
90