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.
@@ -18,6 +18,18 @@ module Browserctl
18
18
  PROTOCOL_MISMATCH = "PROTOCOL_MISMATCH"
19
19
  DOMAIN_NOT_ALLOWED = "DOMAIN_NOT_ALLOWED"
20
20
  KEY_NOT_FOUND = "KEY_NOT_FOUND"
21
+
22
+ # Validation family — introduced in v0.14 WS-1 to retire the remaining
23
+ # bare ArgumentError raises on public APIs and DSL guards.
24
+ # VALIDATION_FAILED is the parent code; the four INVALID_* members are
25
+ # specialisations that all share exit code 8. See docs/reference/errors.md
26
+ # for the per-code triggers.
27
+ VALIDATION_FAILED = "VALIDATION_FAILED"
28
+ INVALID_SELECTOR_REF = "INVALID_SELECTOR_REF"
29
+ INVALID_STATE_NAME = "INVALID_STATE_NAME"
30
+ INVALID_DSL_USAGE = "INVALID_DSL_USAGE"
31
+ INVALID_FORMAT_VERSION = "INVALID_FORMAT_VERSION"
32
+
21
33
  GENERIC = "GENERIC"
22
34
 
23
35
  ALL = [
@@ -29,6 +41,11 @@ module Browserctl
29
41
  PROTOCOL_MISMATCH,
30
42
  DOMAIN_NOT_ALLOWED,
31
43
  KEY_NOT_FOUND,
44
+ VALIDATION_FAILED,
45
+ INVALID_SELECTOR_REF,
46
+ INVALID_STATE_NAME,
47
+ INVALID_DSL_USAGE,
48
+ INVALID_FORMAT_VERSION,
32
49
  GENERIC
33
50
  ].freeze
34
51
 
@@ -25,6 +25,7 @@ module Browserctl
25
25
  PROTOCOL_MISMATCH = 5
26
26
  SELECTOR_NOT_FOUND = 6
27
27
  STATE_EXPIRED = 7
28
+ VALIDATION_FAILED = 8
28
29
 
29
30
  # Canonical Codes string → exit status integer. Codes without an entry
30
31
  # (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
@@ -34,12 +35,22 @@ module Browserctl
34
35
  # DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
35
36
  # entry in this table — drift-related raises fall through to GENERIC
36
37
  # until that code is introduced.
38
+ #
39
+ # The validation family (VALIDATION_FAILED parent plus INVALID_*
40
+ # specialisations) all map to exit code 8 — agents and scripts can
41
+ # branch on `$? == 8` for any caller-side validation failure without
42
+ # caring which specific guard tripped.
37
43
  TABLE = {
38
44
  Codes::AUTH_REQUIRED => AUTH_REQUIRED,
39
45
  Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
40
46
  Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
41
47
  Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
42
- Codes::STATE_EXPIRED => STATE_EXPIRED
48
+ Codes::STATE_EXPIRED => STATE_EXPIRED,
49
+ Codes::VALIDATION_FAILED => VALIDATION_FAILED,
50
+ Codes::INVALID_SELECTOR_REF => VALIDATION_FAILED,
51
+ Codes::INVALID_STATE_NAME => VALIDATION_FAILED,
52
+ Codes::INVALID_DSL_USAGE => VALIDATION_FAILED,
53
+ Codes::INVALID_FORMAT_VERSION => VALIDATION_FAILED
43
54
  }.freeze
44
55
 
45
56
  # @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
@@ -28,6 +28,17 @@ module Browserctl
28
28
  "Add the domain to your policy allowlist or use an allowed URL.",
29
29
  Codes::KEY_NOT_FOUND =>
30
30
  "Verify the key was stored in this daemon session before fetching.",
31
+ Codes::VALIDATION_FAILED =>
32
+ "Check the argument or DSL usage against the documented contract, then retry.",
33
+ Codes::INVALID_SELECTOR_REF =>
34
+ "Pass either a CSS selector or a stable ref — one is required.",
35
+ Codes::INVALID_STATE_NAME =>
36
+ "Use only letters, digits, '_' or '-' (max 64 chars) for state names.",
37
+ Codes::INVALID_DSL_USAGE =>
38
+ "Check the workflow/flow DSL call against docs/reference/style-guide.md; " \
39
+ "required blocks or arguments are missing.",
40
+ Codes::INVALID_FORMAT_VERSION =>
41
+ "Use a non-negative Integer for the format version header; see docs/reference/format-versions.md.",
31
42
  Codes::GENERIC => DEFAULT
32
43
  }.freeze
33
44
 
@@ -63,19 +63,37 @@ module Browserctl
63
63
  end
64
64
 
65
65
  def precondition(label = "precondition", &block)
66
- raise ArgumentError, "precondition '#{label}' requires a block" unless block
66
+ unless block
67
+ raise Browserctl::Error.new(
68
+ "precondition '#{label}' requires a block",
69
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
70
+ context: { dsl: :flow, action: :precondition, label: label }
71
+ )
72
+ end
67
73
 
68
74
  @preconditions << FlowConditionDef.new(kind: :precondition, label: label, block: block)
69
75
  end
70
76
 
71
77
  def postcondition(label = "postcondition", &block)
72
- raise ArgumentError, "postcondition '#{label}' requires a block" unless block
78
+ unless block
79
+ raise Browserctl::Error.new(
80
+ "postcondition '#{label}' requires a block",
81
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
82
+ context: { dsl: :flow, action: :postcondition, label: label }
83
+ )
84
+ end
73
85
 
74
86
  @postconditions << FlowConditionDef.new(kind: :postcondition, label: label, block: block)
75
87
  end
76
88
 
77
89
  def produces_state(&block)
78
- raise ArgumentError, "produces_state requires a block" unless block
90
+ unless block
91
+ raise Browserctl::Error.new(
92
+ "produces_state requires a block",
93
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
94
+ context: { dsl: :flow, action: :produces_state }
95
+ )
96
+ end
79
97
 
80
98
  @produces_state_block = block
81
99
  end
@@ -87,13 +105,22 @@ module Browserctl
87
105
  def compose(target_name)
88
106
  name = target_name.to_s
89
107
  if Browserctl.respond_to?(:lookup_workflow) && Browserctl.lookup_workflow(name)
90
- raise ArgumentError,
91
- "flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
92
- "workflows share state — composition across kinds is not supported"
108
+ raise Browserctl::Error.new(
109
+ "flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
110
+ "workflows share state — composition across kinds is not supported",
111
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
112
+ context: { dsl: :flow, action: :compose, source: @name, target: name, reason: :cross_kind }
113
+ )
93
114
  end
94
115
 
95
116
  source = Browserctl.lookup_flow(name)
96
- raise ArgumentError, "flow '#{name}' not found for composition" unless source
117
+ unless source
118
+ raise Browserctl::Error.new(
119
+ "flow '#{name}' not found for composition",
120
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
121
+ context: { dsl: :flow, action: :compose, source: @name, target: name, reason: :not_found }
122
+ )
123
+ end
97
124
 
98
125
  @steps.concat(source.steps)
99
126
  end
@@ -113,7 +140,11 @@ module Browserctl
113
140
  def validate_semver!(value, label:)
114
141
  return if value.to_s.match?(SEMVER_RE)
115
142
 
116
- raise ArgumentError, "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})"
143
+ raise Browserctl::Error.new(
144
+ "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})",
145
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
146
+ context: { dsl: :flow, action: :validate_semver, label: label, value: value.to_s }
147
+ )
117
148
  end
118
149
 
119
150
  def missing_param_error(name)
@@ -166,7 +197,13 @@ module Browserctl
166
197
  @flow_registry = {}
167
198
 
168
199
  def self.flow(name, &block)
169
- raise ArgumentError, "Browserctl.flow requires a block" unless block
200
+ unless block
201
+ raise Browserctl::Error.new(
202
+ "Browserctl.flow requires a block",
203
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
204
+ context: { dsl: :flow, action: :define, name: name.to_s }
205
+ )
206
+ end
170
207
 
171
208
  flow = Flow.new(name).tap { |f| f.instance_exec(&block) }
172
209
  register_flow(flow)
@@ -60,7 +60,11 @@ module Browserctl
60
60
  def self.validate_name!(name)
61
61
  return if SAFE_NAME.match?(name.to_s)
62
62
 
63
- raise ArgumentError, "invalid flow name: #{name.inspect} — use letters, digits, _ and - only"
63
+ raise Browserctl::Error.new(
64
+ "invalid flow name: #{name.inspect} — use letters, digits, _ and - only",
65
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
66
+ context: { name: name.to_s }
67
+ )
64
68
  end
65
69
  end
66
70
  end
@@ -31,7 +31,11 @@ module Browserctl
31
31
 
32
32
  def char_to_bits(char)
33
33
  idx = BASE32_ALPHABET.index(char) or
34
- raise ArgumentError, "invalid base32 char #{char.inspect}"
34
+ raise Browserctl::Error.new(
35
+ "invalid base32 char #{char.inspect}",
36
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
37
+ context: { char: char }
38
+ )
35
39
  idx.to_s(2).rjust(5, "0")
36
40
  end
37
41
  end
@@ -29,7 +29,13 @@ module Browserctl
29
29
 
30
30
  # Returns the canonical header string for a given integer version.
31
31
  def stamp(version:)
32
- raise ArgumentError, "version must be a non-negative Integer" unless version.is_a?(Integer) && version >= 0
32
+ unless version.is_a?(Integer) && version >= 0
33
+ raise Browserctl::Error.new(
34
+ "version must be a non-negative Integer",
35
+ code: Browserctl::Error::Codes::INVALID_FORMAT_VERSION,
36
+ context: { value: version }
37
+ )
38
+ end
33
39
 
34
40
  "version: #{version}\n"
35
41
  end
@@ -132,7 +132,8 @@ module Browserctl
132
132
  log.progname = component
133
133
  log.formatter = JsonlFormatter.new(component: component)
134
134
  log
135
- rescue StandardError
135
+ rescue Errno::EACCES, Errno::ENOENT, Errno::EISDIR, Errno::ENOSPC, Errno::EROFS, IOError => e
136
+ warn "browserctl: failed to build jsonl logger (#{e.class}: #{e.message})"
136
137
  nil
137
138
  end
138
139
 
@@ -42,7 +42,14 @@ module Browserctl
42
42
  # block receives the file path as a keyword argument and must rewrite
43
43
  # the file in place to the new version.
44
44
  def register(format:, from_version:, to_version:, &upgrade)
45
- raise ArgumentError, "upgrade block required" unless upgrade
45
+ unless upgrade
46
+ raise Browserctl::Error.new(
47
+ "upgrade block required",
48
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
49
+ context: { dsl: :migrations, action: :register, format: format,
50
+ from_version: from_version, to_version: to_version }
51
+ )
52
+ end
46
53
 
47
54
  @mutex.synchronize do
48
55
  @registry << Migration.new(format: format, from_version: from_version,
@@ -12,12 +12,13 @@ module RuboCop
12
12
  # Subclasses with their own `default_code` are trusted (the cop does not
13
13
  # try to statically resolve `default_code` across files); the contract
14
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.
15
+ # specific failure mode of inlining a stale code string at the raise
16
+ # site and bypassing the canonical enum.
17
17
  #
18
18
  # @example
19
- # # bad — string literal that isn't a Codes constant
20
- # raise Browserctl::Error, "state expired", code: "state_expired"
19
+ # # bad — any string literal, even one that happens to match a
20
+ # # canonical code today, drifts when the enum is renamed
21
+ # raise Browserctl::Error, "state expired", code: "STATE_EXPIRED"
21
22
  #
22
23
  # # good — Codes constant reference
23
24
  # raise Browserctl::Error, "state expired",
@@ -29,19 +30,16 @@ module RuboCop
29
30
  # The pattern is intentionally narrow. The full default_code-vs-Codes
30
31
  # reconciliation lives in `lib/browserctl/errors.rb` and is covered by
31
32
  # `spec/unit/errors_spec.rb`.
33
+ #
34
+ # The cop was tightened in v0.14 WS-1 PR 5 to remove the previous
35
+ # "canonical SCREAMING_SNAKE string literals are also fine" escape
36
+ # hatch — every `code:` must now be a constant reference so renames in
37
+ # `Codes` propagate through the codebase via the constant, not via a
38
+ # stale whitelist baked into this cop.
32
39
  class TypedError < RuboCop::Cop::Base
33
40
  MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
34
41
  "got string literal %<value>p. See lib/browserctl/error/codes.rb."
35
42
 
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
43
  # Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
46
44
  # `code:` value node.
47
45
  def_node_matcher :browserctl_raise_with_code, <<~PATTERN
@@ -51,16 +49,27 @@ module RuboCop
51
49
  (hash <(pair (sym :code) $_) ...>))
52
50
  PATTERN
53
51
 
52
+ # Matches `raise Browserctl::Foo.new(..., code: <value>, ...)` and
53
+ # yields the `code:` value node. The base error initializer is
54
+ # routinely called via `.new` (see `Browserctl::Error#initialize`),
55
+ # so the cop needs to inspect both shapes.
56
+ def_node_matcher :browserctl_raise_new_with_code, <<~PATTERN
57
+ (send nil? :raise
58
+ (send (const (const nil? :Browserctl) _) :new
59
+ ...
60
+ (hash <(pair (sym :code) $_) ...>)))
61
+ PATTERN
62
+
54
63
  def on_send(node)
55
64
  return unless node.method?(:raise)
56
65
 
57
- browserctl_raise_with_code(node) do |code_value|
66
+ [
67
+ browserctl_raise_with_code(node),
68
+ browserctl_raise_new_with_code(node)
69
+ ].compact.each do |code_value|
58
70
  next unless code_value.str_type?
59
71
 
60
- value = code_value.value
61
- next if VALID_CODES.include?(value)
62
-
63
- add_offense(code_value, message: format(MSG, value: value))
72
+ add_offense(code_value, message: format(MSG, value: code_value.value))
64
73
  end
65
74
  end
66
75
  end
@@ -29,7 +29,7 @@ module Browserctl
29
29
  include Handlers::DevTools
30
30
  include Handlers::DaemonControl
31
31
  include Handlers::Storage
32
- include Handlers::State
32
+ include Handlers::StateRpc
33
33
  include Handlers::Interaction
34
34
 
35
35
  COMMAND_MAP = {
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "timeout"
5
+
3
6
  module Browserctl
4
7
  class CommandDispatcher
5
8
  module Handlers
@@ -75,7 +78,8 @@ module Browserctl
75
78
  def capture_post_snapshot_digest(session)
76
79
  snapshot = @snapshot_builder.call(session.page)
77
80
  Browserctl::Replay::SnapshotDiff.digest(snapshot)
78
- rescue StandardError
81
+ rescue JSON::ParserError, Timeout::Error, Browserctl::Error => e
82
+ Browserctl.logger.debug("post-snapshot digest skipped: #{e.class}: #{e.message}")
79
83
  nil
80
84
  end
81
85
 
@@ -8,7 +8,7 @@ module Browserctl
8
8
  module Handlers
9
9
  # Top-level state management — collapses cookies + localStorage +
10
10
  # sessionStorage into a single `.bctl` bundle. See lib/browserctl/state.rb.
11
- module State
11
+ module StateRpc
12
12
  private
13
13
 
14
14
  def cmd_state_save(req)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../flow_registry"
4
+ require_relative "../errors"
5
+
6
+ module Browserctl
7
+ module State
8
+ # Drives the "rotate this bundle's bound flow and re-save" operation
9
+ # without dragging in CLI input concerns. Extracted from
10
+ # `Browserctl::Commands::State.run_rotate` so the rotation flow is:
11
+ # - testable without spawning a CLI subprocess
12
+ # - reusable from Workflow if a workflow ever needs to drive rotation
13
+ # programmatically.
14
+ #
15
+ # Errors are surfaced by raising typed `Browserctl::FlowError` so the
16
+ # caller (CLI or Workflow) can decide how to render them. The CLI maps
17
+ # to a non-zero exit; a Workflow caller may catch and continue.
18
+ class Mutator
19
+ Result = Struct.new(:save_result, :flow_name, :flow_version, keyword_init: true) do
20
+ def to_h
21
+ (save_result || {}).merge(rotated_flow: flow_name)
22
+ end
23
+ end
24
+
25
+ def initialize(client:, registry: Browserctl::FlowRegistry)
26
+ @client = client
27
+ @registry = registry
28
+ end
29
+
30
+ # Re-runs the flow bound to the bundle <name> and re-saves it under the
31
+ # same origins. `params` is the merged param set (file + caller-provided);
32
+ # the CLI does the file/k=v parsing before handing values in.
33
+ #
34
+ # @return [Result]
35
+ # @raise [Browserctl::FlowError]
36
+ def rotate(name:, params: {}, page: nil)
37
+ manifest = read_manifest!(name)
38
+ flow = resolve_bound_flow!(manifest)
39
+
40
+ flow.run(page: page, client: @client, **params)
41
+
42
+ save_result = @client.state_save(name,
43
+ flow: flow.name,
44
+ flow_version: flow.version_string,
45
+ origins: manifest[:origins] || manifest["origins"])
46
+ Result.new(save_result: save_result, flow_name: flow.name, flow_version: flow.version_string)
47
+ end
48
+
49
+ private
50
+
51
+ def read_manifest!(name)
52
+ info = @client.state_info(name)
53
+ err = info[:error] || info["error"]
54
+ raise Browserctl::FlowError, err.to_s if err
55
+
56
+ info[:info] || info["info"] || {}
57
+ end
58
+
59
+ def resolve_bound_flow!(manifest)
60
+ flow_name = manifest[:flow] || manifest["flow"]
61
+ if flow_name.nil? || flow_name.to_s.empty?
62
+ raise Browserctl::FlowError,
63
+ "state has no bound flow — re-save with `state save --flow NAME` first"
64
+ end
65
+
66
+ flow = @registry.resolve(flow_name)
67
+ raise Browserctl::FlowError, "flow '#{flow_name}' not found in registry" unless flow
68
+
69
+ flow
70
+ end
71
+ end
72
+ end
73
+ end
@@ -83,7 +83,11 @@ module Browserctl
83
83
  def self.validate_name!(name)
84
84
  return if SAFE_NAME.match?(name.to_s)
85
85
 
86
- raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
86
+ raise Browserctl::Error.new(
87
+ "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)",
88
+ code: Browserctl::Error::Codes::INVALID_STATE_NAME,
89
+ context: { name: name }
90
+ )
87
91
  end
88
92
 
89
93
  # Persist a bundle. `payload` is a `State::Payload` value object carrying
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Browserctl
6
+ module Trace
7
+ # Reads `cli.log` + `daemon.log` JSONL files from a log directory and yields
8
+ # records sorted by timestamp. Malformed lines are skipped silently — this
9
+ # matches the tolerant behaviour expected of `browserctl trace`.
10
+ #
11
+ # Session filtering: when `session_filter` is provided, only records whose
12
+ # `session_id` matches are emitted. When nil, records are scoped to the most
13
+ # recent `session_id` observed in the merged stream (or all records if none
14
+ # carry a session id yet — backwards compatible with older logs).
15
+ class EventStream
16
+ include Enumerable
17
+
18
+ LOG_GLOB = "{cli,daemon}.log"
19
+
20
+ def initialize(log_dir, session_filter: nil)
21
+ @log_dir = log_dir
22
+ @session_filter = session_filter
23
+ end
24
+
25
+ def each(&block)
26
+ return enum_for(:each) unless block
27
+
28
+ records.each(&block)
29
+ end
30
+
31
+ def empty?
32
+ records.empty?
33
+ end
34
+
35
+ def records
36
+ @records ||= filter_session(load_records)
37
+ end
38
+
39
+ private
40
+
41
+ def load_records
42
+ paths = Dir.glob(File.join(@log_dir, LOG_GLOB))
43
+ rows = paths.flat_map do |path|
44
+ File.foreach(path).filter_map { |line| parse_line(line) }
45
+ end
46
+ rows.sort_by { |r| r["ts"].to_s }
47
+ end
48
+
49
+ def parse_line(line)
50
+ stripped = line.strip
51
+ return nil if stripped.empty?
52
+
53
+ JSON.parse(stripped)
54
+ rescue JSON::ParserError
55
+ nil
56
+ end
57
+
58
+ # Session resolution. When session_id is stamped on records (future PR),
59
+ # filter/select by it. Otherwise, treat the entire merged stream as one
60
+ # session — caller can scope by tailing/rotating logs.
61
+ # TODO: stamp session_id on every log line so this scopes correctly.
62
+ def filter_session(rows)
63
+ if @session_filter
64
+ rows.select { |r| r["session_id"].to_s == @session_filter }
65
+ else
66
+ ids = rows.map { |r| r["session_id"] }.compact.uniq
67
+ return rows if ids.empty?
68
+
69
+ recent = ids.last
70
+ rows.select { |r| r["session_id"] == recent }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Browserctl
6
+ module Trace
7
+ # Renders structured log records as a human-scannable timeline. Output
8
+ # format is intentionally compact:
9
+ #
10
+ # HH:MM:SS.mmm I LEVEL COMPONENT LABEL k=v k=v
11
+ #
12
+ # Colour is enabled when the target IO is a TTY (or when `color: true` is
13
+ # forced). Redaction is optional and injected as a dependency so callers
14
+ # can substitute a stricter `Browserctl::Redactor` or pass `nil` to
15
+ # disable.
16
+ class Renderer
17
+ LEVEL_COLORS = {
18
+ "DEBUG" => "\e[2;37m", # dim grey
19
+ "INFO" => "\e[36m", # cyan
20
+ "WARN" => "\e[33m", # yellow
21
+ "ERROR" => "\e[31m" # red
22
+ }.freeze
23
+ RESET = "\e[0m"
24
+
25
+ CATEGORY_ICONS = {
26
+ error: "!",
27
+ snapshot: "S",
28
+ network: "N",
29
+ event: "."
30
+ }.freeze
31
+
32
+ OMIT_KEYS = %w[ts level component event msg].freeze
33
+
34
+ def initialize(io:, color: nil, redactor: nil)
35
+ @io = io
36
+ @color = color.nil? ? tty?(io) : color
37
+ @redactor = redactor
38
+ end
39
+
40
+ def render(stream)
41
+ stream.each { |record| @io.puts(format_line(record)) }
42
+ end
43
+
44
+ private
45
+
46
+ def tty?(io)
47
+ io.respond_to?(:tty?) && io.tty?
48
+ end
49
+
50
+ def format_line(record)
51
+ level = (record["level"] || "INFO").to_s
52
+ line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
53
+ ts: format_ts(record["ts"]),
54
+ icon: CATEGORY_ICONS.fetch(categorise(record), "."),
55
+ level: level,
56
+ comp: (record["component"] || "?").to_s,
57
+ label: event_label(record),
58
+ ctx: context_snippet(record)).rstrip
59
+
60
+ line = @redactor.redact(line) if @redactor
61
+ @color ? colourise(line, level) : line
62
+ end
63
+
64
+ def format_ts(timestamp)
65
+ Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
66
+ rescue ArgumentError, TypeError
67
+ "??:??:??.???"
68
+ end
69
+
70
+ def categorise(record)
71
+ return :error if record["level"] == "ERROR" || record["error"]
72
+ return :snapshot if record["snapshot"]
73
+ return :network if record["request"] || record["response"] || record["url"]
74
+
75
+ :event
76
+ end
77
+
78
+ def event_label(record)
79
+ (record["event"] || record["snapshot"] || record["request"] ||
80
+ record["msg"] || "-").to_s.slice(0, 22)
81
+ end
82
+
83
+ # Compact "k=v k=v" snippet of remaining structured keys, capped to keep
84
+ # the timeline scannable. Skips fields already shown in fixed columns.
85
+ def context_snippet(record)
86
+ pairs = record.except(*OMIT_KEYS)
87
+ return "" if pairs.empty?
88
+
89
+ pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
90
+ end
91
+
92
+ def format_value(value)
93
+ case value
94
+ when String then value.length > 40 ? "#{value[0, 37]}..." : value
95
+ when Array then "[#{value.length}]"
96
+ when Hash then "{#{value.keys.length}}"
97
+ else value.to_s
98
+ end
99
+ end
100
+
101
+ def colourise(line, level)
102
+ colour = LEVEL_COLORS[level] || ""
103
+ "#{colour}#{line}#{RESET}"
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.13.1"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -320,9 +320,12 @@ module Browserctl
320
320
  def compose(workflow_name)
321
321
  name = workflow_name.to_s
322
322
  if Browserctl.lookup_flow(name)
323
- raise ArgumentError,
324
- "workflow '#{@name}' cannot compose flow '#{name}': flows return state, " \
325
- "workflows share state — composition across kinds is not supported"
323
+ raise Browserctl::Error.new(
324
+ "workflow '#{@name}' cannot compose flow '#{name}': flows return state, " \
325
+ "workflows share state — composition across kinds is not supported",
326
+ code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
327
+ context: { workflow: @name, flow: name, kind: :cross_kind_compose }
328
+ )
326
329
  end
327
330
 
328
331
  source = Browserctl.lookup_workflow(name)