browserctl 0.10.0 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +1 -1
  4. data/bin/browserctl +45 -4
  5. data/lib/browserctl/client.rb +47 -3
  6. data/lib/browserctl/commands/cli_output.rb +16 -3
  7. data/lib/browserctl/commands/flow.rb +123 -0
  8. data/lib/browserctl/commands/state.rb +193 -0
  9. data/lib/browserctl/commands/workflow.rb +62 -4
  10. data/lib/browserctl/constants.rb +1 -1
  11. data/lib/browserctl/detectors/auth_required.rb +128 -0
  12. data/lib/browserctl/detectors.rb +2 -0
  13. data/lib/browserctl/errors.rb +30 -0
  14. data/lib/browserctl/flow.rb +22 -1
  15. data/lib/browserctl/flow_registry.rb +66 -0
  16. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  17. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  18. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  19. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  20. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  21. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  22. data/lib/browserctl/recording.rb +212 -26
  23. data/lib/browserctl/replay/context.rb +40 -0
  24. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  25. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  26. data/lib/browserctl/replay/telemetry.rb +60 -0
  27. data/lib/browserctl/runner.rb +38 -4
  28. data/lib/browserctl/server/command_dispatcher.rb +10 -1
  29. data/lib/browserctl/server/handlers/interaction.rb +3 -3
  30. data/lib/browserctl/server/handlers/navigation.rb +33 -4
  31. data/lib/browserctl/server/handlers/observation.rb +43 -2
  32. data/lib/browserctl/server/handlers/state.rb +149 -0
  33. data/lib/browserctl/server/page_session.rb +9 -7
  34. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  35. data/lib/browserctl/snapshot/annotator.rb +75 -0
  36. data/lib/browserctl/snapshot/extractor.rb +21 -0
  37. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  38. data/lib/browserctl/snapshot/ref.rb +70 -0
  39. data/lib/browserctl/snapshot/serializer.rb +17 -0
  40. data/lib/browserctl/state/bundle.rb +242 -0
  41. data/lib/browserctl/state/transport.rb +64 -0
  42. data/lib/browserctl/state/transports/file.rb +35 -0
  43. data/lib/browserctl/state/transports/one_password.rb +67 -0
  44. data/lib/browserctl/state/transports/s3.rb +42 -0
  45. data/lib/browserctl/state.rb +208 -0
  46. data/lib/browserctl/version.rb +1 -1
  47. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  48. data/lib/browserctl/workflow/promoter.rb +96 -0
  49. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  50. data/lib/browserctl/workflow.rb +180 -16
  51. metadata +31 -2
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../constants"
5
+
6
+ module Browserctl
7
+ module Workflow
8
+ # Renders a `Browserctl.flow` definition that wraps a promoted workflow.
9
+ # The flow becomes a globally-registered, parameterised handle that runs
10
+ # the underlying workflow via `Runner#run_workflow`. Params are inferred
11
+ # from the workflow's `param_defs` so callers see the same surface area
12
+ # they would on the workflow itself.
13
+ #
14
+ # Wrapping (rather than translating step-by-step) keeps the workflow as
15
+ # the single source of truth: edits to the workflow file flow through
16
+ # to the wrapper without regeneration.
17
+ module FlowWrapper
18
+ module_function
19
+
20
+ def target_dir
21
+ File.join(Browserctl::BROWSERCTL_DIR, "flows")
22
+ end
23
+
24
+ def target_path(name)
25
+ File.join(target_dir, "#{name}.rb")
26
+ end
27
+
28
+ # @param defn [Browserctl::WorkflowDefinition]
29
+ # @return [String] Ruby source for a flow file
30
+ def render(defn)
31
+ params = defn.param_defs.values.map { |p| render_param(p) }.join("\n")
32
+ desc = defn.description || "Promoted from workflow '#{defn.name}'"
33
+ <<~RUBY
34
+ # frozen_string_literal: true
35
+
36
+ require "browserctl/flow"
37
+ require "browserctl/runner"
38
+
39
+ # Auto-generated flow wrapper for workflow '#{defn.name}'.
40
+ # Edit the underlying workflow file rather than this wrapper.
41
+ Browserctl.flow(#{defn.name.inspect}) do
42
+ version "1.0.0"
43
+ requires_browserctl "0.11.0"
44
+ desc #{desc.inspect}
45
+
46
+ #{params.gsub(/^/, ' ') unless params.empty?}
47
+
48
+ step("run workflow #{defn.name}") do
49
+ Browserctl::Runner.new.run_workflow(#{defn.name.inspect}, **params)
50
+ end
51
+ end
52
+ RUBY
53
+ end
54
+
55
+ # @param defn [Browserctl::WorkflowDefinition]
56
+ # @param overwrite [Boolean]
57
+ # @param dir [String, nil] override target dir (testing)
58
+ # @return [String] path written
59
+ def write(defn, overwrite: true, dir: nil)
60
+ path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
61
+ if File.exist?(path) && !overwrite
62
+ raise "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
63
+ end
64
+
65
+ FileUtils.mkdir_p(File.dirname(path))
66
+ File.write(path, render(defn))
67
+ path
68
+ end
69
+
70
+ def render_param(param)
71
+ opts = []
72
+ opts << "required: true" if param.required
73
+ opts << "secret: true" if param.secret && !param.secret_ref
74
+ opts << "secret_ref: #{param.secret_ref.inspect}" if param.secret_ref
75
+ opts << "default: #{param.default.inspect}" unless param.default.nil?
76
+ suffix = opts.empty? ? "" : ", #{opts.join(', ')}"
77
+ "param :#{param.name}#{suffix}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../constants"
5
+ require_relative "promotion_ledger"
6
+ require_relative "flow_wrapper"
7
+
8
+ module Browserctl
9
+ module Workflow
10
+ # Promotes a workflow file from the project-local `.browserctl/workflows/`
11
+ # directory to the user-global `~/.browserctl/workflows/` directory, where
12
+ # it is invocable from any project.
13
+ #
14
+ # Promotion is gated by `PromotionLedger.clean_streak`: a workflow must
15
+ # have at least `threshold` consecutive clean `--check` runs before it
16
+ # can be promoted. `--force` overrides the gate.
17
+ module Promoter
18
+ class IneligibleError < StandardError
19
+ attr_reader :streak, :threshold
20
+
21
+ def initialize(workflow:, streak:, threshold:)
22
+ @streak = streak
23
+ @threshold = threshold
24
+ super(
25
+ "workflow '#{workflow}' has #{streak} clean --check run(s); " \
26
+ "needs #{threshold}. Run `browserctl workflow run #{workflow} --check` " \
27
+ "until clean, or pass --force to override."
28
+ )
29
+ end
30
+ end
31
+
32
+ class NotFoundError < StandardError; end
33
+
34
+ module_function
35
+
36
+ DEFAULT_SOURCE_DIR = ".browserctl/workflows"
37
+
38
+ def target_dir
39
+ File.join(Browserctl::BROWSERCTL_DIR, "workflows")
40
+ end
41
+
42
+ def source_path(workflow, source_dir: DEFAULT_SOURCE_DIR)
43
+ File.join(source_dir, "#{workflow}.rb")
44
+ end
45
+
46
+ def target_path(workflow)
47
+ File.join(target_dir, "#{workflow}.rb")
48
+ end
49
+
50
+ # @param workflow [String]
51
+ # @param force [Boolean]
52
+ # @param threshold [Integer]
53
+ # @param source_dir [String] override the source directory (testing)
54
+ # @param ledger_path [String] override the ledger path (testing)
55
+ # @return [Hash] `{ workflow:, source:, target:, streak:, threshold:, forced: }`
56
+ def promote(workflow:, force: false, threshold: PromotionLedger::DEFAULT_THRESHOLD, # rubocop:disable Metrics/ParameterLists
57
+ as_flow: false, source_dir: DEFAULT_SOURCE_DIR,
58
+ ledger_path: PromotionLedger.ledger_path, flow_dir: nil)
59
+ src = source_path(workflow, source_dir: source_dir)
60
+ raise NotFoundError, "workflow file not found: #{src}" unless File.exist?(src)
61
+
62
+ streak = PromotionLedger.clean_streak(workflow: workflow, path: ledger_path)
63
+ unless force || streak >= threshold
64
+ raise IneligibleError.new(workflow: workflow, streak: streak, threshold: threshold)
65
+ end
66
+
67
+ dst = target_path(workflow)
68
+ FileUtils.mkdir_p(File.dirname(dst))
69
+ FileUtils.cp(src, dst)
70
+
71
+ result = {
72
+ workflow: workflow,
73
+ source: src,
74
+ target: dst,
75
+ streak: streak,
76
+ threshold: threshold,
77
+ forced: force && streak < threshold
78
+ }
79
+
80
+ result[:flow] = wrap_as_flow(workflow, dst, flow_dir) if as_flow
81
+ result
82
+ end
83
+
84
+ # Loads the just-promoted workflow file, infers params from its
85
+ # WorkflowDefinition, and writes a flow wrapper at `flow_dir`
86
+ # (defaults to `~/.browserctl/flows/<name>.rb`).
87
+ def wrap_as_flow(workflow, workflow_path, flow_dir)
88
+ load workflow_path
89
+ defn = Browserctl.lookup_workflow(workflow.to_s) or
90
+ raise NotFoundError, "workflow '#{workflow}' did not register after load"
91
+
92
+ FlowWrapper.write(defn, overwrite: true, dir: flow_dir)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "../constants"
7
+
8
+ module Browserctl
9
+ module Workflow
10
+ # Append-only JSONL ledger of `workflow run --check` outcomes per workflow.
11
+ # Used as the gate for `workflow promote`: only workflows with a sufficient
12
+ # streak of clean runs are eligible for promotion to `~/.browserctl/workflows/`.
13
+ #
14
+ # Record schema (one JSONL line):
15
+ # { "ts": "2026-05-10T12:00:00Z", "workflow": "name", "verdict": "clean" }
16
+ module PromotionLedger
17
+ LEDGER_BASENAME = "check_ledger.jsonl"
18
+ DEFAULT_THRESHOLD = 3
19
+ VALID_VERDICTS = %i[clean drift fail].freeze
20
+
21
+ module_function
22
+
23
+ def ledger_path
24
+ File.join(Browserctl::BROWSERCTL_DIR, LEDGER_BASENAME)
25
+ end
26
+
27
+ # Append a verdict for a workflow run.
28
+ # @param workflow [String]
29
+ # @param verdict [Symbol] :clean, :drift, or :fail
30
+ # @param path [String] override (testing)
31
+ def record(workflow:, verdict:, path: ledger_path, at: Time.now.utc)
32
+ return unless VALID_VERDICTS.include?(verdict)
33
+
34
+ FileUtils.mkdir_p(File.dirname(path))
35
+ File.open(path, "a") do |f|
36
+ f.puts JSON.generate(
37
+ ts: at.iso8601,
38
+ workflow: workflow.to_s,
39
+ verdict: verdict.to_s
40
+ )
41
+ end
42
+ end
43
+
44
+ # Count the trailing streak of :clean verdicts for a workflow.
45
+ # A non-clean verdict resets the streak. Drift and fail both break it
46
+ # — the gate is intentionally strict; users can override with --force.
47
+ # @return [Integer]
48
+ def clean_streak(workflow:, path: ledger_path)
49
+ return 0 unless File.exist?(path)
50
+
51
+ streak = 0
52
+ File.foreach(path) do |line|
53
+ entry = parse(line) or next
54
+ next unless entry["workflow"] == workflow.to_s
55
+
56
+ if entry["verdict"] == "clean"
57
+ streak += 1
58
+ else
59
+ streak = 0
60
+ end
61
+ end
62
+ streak
63
+ end
64
+
65
+ def parse(line)
66
+ JSON.parse(line)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -3,6 +3,10 @@
3
3
  require "timeout"
4
4
  require_relative "client"
5
5
  require_relative "errors"
6
+ require_relative "flow_registry"
7
+ require_relative "replay/context"
8
+ require_relative "replay/fingerprint_matcher"
9
+ require_relative "replay/snapshot_diff"
6
10
  require_relative "secret_resolvers"
7
11
  require_relative "session"
8
12
 
@@ -12,11 +16,12 @@ module Browserctl
12
16
  StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
13
17
 
14
18
  class WorkflowContext
15
- attr_reader :client
19
+ attr_reader :client, :replay_context, :params
16
20
 
17
- def initialize(params, client)
21
+ def initialize(params, client, replay_context: nil)
18
22
  @params = params
19
23
  @client = client
24
+ @replay_context = replay_context
20
25
  end
21
26
 
22
27
  def store(key, value)
@@ -44,7 +49,7 @@ module Browserctl
44
49
  end
45
50
 
46
51
  def page(name)
47
- PageProxy.new(name.to_s, @client)
52
+ PageProxy.new(name.to_s, @client, replay_context: @replay_context)
48
53
  end
49
54
 
50
55
  def open_page(page_name, url: nil)
@@ -68,7 +73,39 @@ module Browserctl
68
73
  res
69
74
  end
70
75
 
76
+ # Persists the daemon's current cookies + storage as a .bctl bundle.
77
+ # Optional flow binding lets `load_state` auto-rotate when the bundle
78
+ # is detected as needing authentication.
79
+ def save_state(name, flow: nil, origins: nil, encrypt: false)
80
+ passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
81
+ res = @client.state_save(name.to_s,
82
+ flow: flow&.to_s, origins: origins, passphrase: passphrase)
83
+ raise WorkflowError, res[:error] if res[:error]
84
+
85
+ res
86
+ end
87
+
88
+ # Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
89
+ # applying (e.g. expired cookies in the payload), this rotates the bound
90
+ # flow and retries — no caller code change required.
91
+ #
92
+ # @param on_auth_required [Proc, nil] override the auto-rotate path. The
93
+ # block runs in the workflow context, in lieu of invoking the manifest's
94
+ # bound flow. Use this when the recovery procedure is bespoke.
95
+ def load_state(name, on_auth_required: nil)
96
+ res = @client.state_load(name.to_s)
97
+ return res unless auth_required_response?(res)
98
+
99
+ recover_auth_required_state(name.to_s, res, on_auth_required)
100
+ end
101
+ DEPRECATED_LOAD_SESSION_FALLBACK = <<~MSG
102
+ [browserctl] DEPRECATION: `load_session(name, fallback:, expired_if:)` is superseded by
103
+ `load_state(name)` with a flow-bound bundle (`save_state(name, flow: :name)`).
104
+ `load_session` will be removed in v0.12. See docs/concepts/state.md.
105
+ MSG
106
+
71
107
  def load_session(session_name, fallback: nil, expired_if: nil)
108
+ warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
72
109
  validate_expired_if!(expired_if)
73
110
  fallback_name = fallback&.to_s
74
111
  res = @client.session_load(session_name)
@@ -94,16 +131,41 @@ module Browserctl
94
131
  $stdin.gets.chomp
95
132
  end
96
133
 
97
- def invoke(workflow_name, **override_params)
98
- name = workflow_name.to_s
134
+ def invoke(target_name, page: nil, **override_params)
135
+ name = target_name.to_s
99
136
  guard_circular!(name)
100
- track_invoke(name) { run_nested(workflow_name, **override_params) }
137
+
138
+ flow = lookup_flow_target(name)
139
+ if flow
140
+ track_invoke(name) { run_invoked_flow(flow, page_name: page, **override_params) }
141
+ else
142
+ track_invoke(name) { run_nested(target_name, **override_params) }
143
+ end
101
144
  end
102
145
 
103
146
  def assert(condition, msg = "assertion failed")
104
147
  raise WorkflowError, msg unless condition
105
148
  end
106
149
 
150
+ # Snapshots the named page and compares its digest against `expected_digest`.
151
+ # Under `workflow run --check` (a replay context is attached), a mismatch is
152
+ # recorded as a drift event with reason "post-snapshot mismatch" and the
153
+ # step still passes. Outside --check, mismatch raises WorkflowError so the
154
+ # workflow fails fast.
155
+ def assert_snapshot_stable(page_name, expected_digest:)
156
+ res = @client.snapshot(page_name.to_s, format: "elements")
157
+ snapshot = res[:snapshot]
158
+ actual = Replay::SnapshotDiff.digest(snapshot)
159
+ return if actual == expected_digest
160
+
161
+ msg = "post-snapshot mismatch on :#{page_name} — expected #{expected_digest}, got #{actual}"
162
+ raise WorkflowError, msg unless @replay_context
163
+
164
+ @replay_context.record(command: :assert_snapshot_stable, selector: page_name.to_s,
165
+ matched_ref: nil, score: nil, reason: "post-snapshot mismatch")
166
+ warn "[browserctl replay] #{msg}"
167
+ end
168
+
107
169
  def compose(*)
108
170
  raise WorkflowError,
109
171
  "`compose` must be called at the workflow definition level, not inside a step block. " \
@@ -112,6 +174,45 @@ module Browserctl
112
174
 
113
175
  private
114
176
 
177
+ def auth_required_response?(res)
178
+ (res[:code] || res["code"]) == "AUTH_REQUIRED"
179
+ end
180
+
181
+ def recover_auth_required_state(name, initial_res, on_auth_required)
182
+ if on_auth_required
183
+ on_auth_required.call
184
+ else
185
+ flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
186
+ unless flow_name && !flow_name.to_s.empty?
187
+ raise WorkflowError,
188
+ "state '#{name}' needs auth but bundle has no bound flow — " \
189
+ "save with `save_state('#{name}', flow: :NAME)` or pass on_auth_required:"
190
+ end
191
+
192
+ # Match the daemon's `state load` preflight: it auth-checks the first
193
+ # open page (insertion order). Passing that same name to the flow
194
+ # gives stdlib flows a `page` proxy to drive (oauth_github reads
195
+ # `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
196
+ # page only when nothing is open — `state_save` would have errored
197
+ # earlier in that case, so this is a defence-in-depth nil.
198
+ invoke(flow_name, page: first_open_page)
199
+ end
200
+
201
+ after_save = @client.state_save(name)
202
+ raise WorkflowError, after_save[:error] if after_save[:error]
203
+
204
+ retry_res = @client.state_load(name, skip_auth_check: true)
205
+ raise WorkflowError, retry_res[:error] if retry_res[:error]
206
+
207
+ retry_res.merge(rotated: true)
208
+ end
209
+
210
+ def first_open_page
211
+ res = @client.page_list
212
+ pages = res[:pages] || res["pages"] || []
213
+ pages.first
214
+ end
215
+
115
216
  def validate_expired_if!(expired_if)
116
217
  return unless expired_if
117
218
 
@@ -182,22 +283,43 @@ module Browserctl
182
283
  def run_nested(workflow_name, **override_params)
183
284
  Runner.new.run_workflow(workflow_name, **@params, **override_params)
184
285
  end
286
+
287
+ def lookup_flow_target(name)
288
+ Browserctl.lookup_flow(name) || begin
289
+ FlowRegistry.resolve(name)
290
+ rescue ArgumentError
291
+ nil
292
+ end
293
+ end
294
+
295
+ def run_invoked_flow(flow, page_name:, **params)
296
+ proxy = page_name ? page(page_name) : nil
297
+ flow.run(page: proxy, client: @client, **params)
298
+ end
185
299
  end
186
300
 
187
301
  class PageProxy
188
- def initialize(name, client)
189
- @name = name
190
- @client = client
302
+ attr_accessor :replay_context
303
+
304
+ def initialize(name, client, replay_context: nil, matcher: nil)
305
+ @name = name
306
+ @client = client
307
+ @replay_context = replay_context
308
+ @matcher = matcher || Replay::FingerprintMatcher.new
191
309
  end
192
310
 
193
311
  def navigate(url) = unwrap @client.navigate(@name, url)
194
312
 
195
313
  def fill(selector = nil, value = nil, ref: nil)
196
- unwrap @client.fill(@name, selector, value, ref: ref)
314
+ with_selector_fallback(:fill, selector, ref) do |sel, r|
315
+ @client.fill(@name, sel, value, ref: r)
316
+ end
197
317
  end
198
318
 
199
319
  def click(selector = nil, ref: nil)
200
- unwrap @client.click(@name, selector, ref: ref)
320
+ with_selector_fallback(:click, selector, ref) do |sel, r|
321
+ @client.click(@name, sel, ref: r)
322
+ end
201
323
  end
202
324
 
203
325
  def snapshot(**) = unwrap @client.snapshot(@name, **)
@@ -219,15 +341,21 @@ module Browserctl
219
341
  def press(key) = unwrap @client.press(@name, key)
220
342
 
221
343
  def hover(selector = nil, ref: nil)
222
- unwrap @client.hover(@name, selector, ref: ref)
344
+ with_selector_fallback(:hover, selector, ref) do |sel, r|
345
+ @client.hover(@name, sel, ref: r)
346
+ end
223
347
  end
224
348
 
225
349
  def upload(selector = nil, path = nil, ref: nil)
226
- unwrap @client.upload(@name, selector, path, ref: ref)
350
+ with_selector_fallback(:upload, selector, ref) do |sel, r|
351
+ @client.upload(@name, sel, path, ref: r)
352
+ end
227
353
  end
228
354
 
229
355
  def select(selector = nil, value = nil, ref: nil)
230
- unwrap @client.select(@name, selector, value, ref: ref)
356
+ with_selector_fallback(:select, selector, ref) do |sel, r|
357
+ @client.select(@name, sel, value, ref: r)
358
+ end
231
359
  end
232
360
 
233
361
  def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
@@ -235,6 +363,42 @@ module Browserctl
235
363
 
236
364
  private
237
365
 
366
+ # Issues the wrapped command. If the daemon returns selector_not_found
367
+ # and a replay context has a fingerprint for this selector, takes a
368
+ # fresh snapshot, asks the matcher for a candidate, and retries by ref.
369
+ def with_selector_fallback(cmd, selector, ref)
370
+ res = yield(selector, ref)
371
+ return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
372
+
373
+ fp = @replay_context.fingerprint_for(selector)
374
+ return unwrap(res) unless fp
375
+
376
+ match = @matcher.best(fp, snapshot_entries)
377
+ unless match
378
+ @replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
379
+ return unwrap(res)
380
+ end
381
+
382
+ log_rematch(cmd, selector, match)
383
+ @replay_context.record(command: cmd, selector: selector,
384
+ matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
385
+ unwrap(yield(nil, match.candidate[:ref]))
386
+ end
387
+
388
+ def snapshot_entries
389
+ res = @client.snapshot(@name, format: "elements")
390
+ Array(res[:snapshot])
391
+ end
392
+
393
+ def selector_not_found?(res)
394
+ res.is_a?(Hash) && res[:code] == "selector_not_found"
395
+ end
396
+
397
+ def log_rematch(cmd, selector, match)
398
+ warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
399
+ "rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
400
+ end
401
+
238
402
  def unwrap(res)
239
403
  raise WorkflowError, res[:error] if res[:error]
240
404
 
@@ -273,8 +437,8 @@ module Browserctl
273
437
  @steps.concat(source.steps)
274
438
  end
275
439
 
276
- def call(params, client)
277
- ctx = WorkflowContext.new(resolve_params(params), client)
440
+ def call(params, client, replay_context: nil)
441
+ ctx = WorkflowContext.new(resolve_params(params), client, replay_context: replay_context)
278
442
  execute_steps(ctx)
279
443
  end
280
444
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-09 00:00:00.000000000 Z
11
+ date: 2026-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -174,6 +174,7 @@ files:
174
174
  - lib/browserctl/commands/daemon.rb
175
175
  - lib/browserctl/commands/dialog.rb
176
176
  - lib/browserctl/commands/fill.rb
177
+ - lib/browserctl/commands/flow.rb
177
178
  - lib/browserctl/commands/init.rb
178
179
  - lib/browserctl/commands/page.rb
179
180
  - lib/browserctl/commands/record.rb
@@ -181,19 +182,32 @@ files:
181
182
  - lib/browserctl/commands/screenshot.rb
182
183
  - lib/browserctl/commands/session.rb
183
184
  - lib/browserctl/commands/snapshot.rb
185
+ - lib/browserctl/commands/state.rb
184
186
  - lib/browserctl/commands/storage.rb
185
187
  - lib/browserctl/commands/workflow.rb
186
188
  - lib/browserctl/constants.rb
187
189
  - lib/browserctl/detectors.rb
190
+ - lib/browserctl/detectors/auth_required.rb
188
191
  - lib/browserctl/driver.rb
189
192
  - lib/browserctl/driver/base.rb
190
193
  - lib/browserctl/driver/cdp.rb
191
194
  - lib/browserctl/driver/cdp_page.rb
192
195
  - lib/browserctl/errors.rb
193
196
  - lib/browserctl/flow.rb
197
+ - lib/browserctl/flow_registry.rb
198
+ - lib/browserctl/flows/stdlib/basic_auth.rb
199
+ - lib/browserctl/flows/stdlib/cloudflare_solve.rb
200
+ - lib/browserctl/flows/stdlib/magic_link_email.rb
201
+ - lib/browserctl/flows/stdlib/oauth_github.rb
202
+ - lib/browserctl/flows/stdlib/oauth_google.rb
203
+ - lib/browserctl/flows/stdlib/totp_2fa.rb
194
204
  - lib/browserctl/logger.rb
195
205
  - lib/browserctl/policy.rb
196
206
  - lib/browserctl/recording.rb
207
+ - lib/browserctl/replay/context.rb
208
+ - lib/browserctl/replay/fingerprint_matcher.rb
209
+ - lib/browserctl/replay/snapshot_diff.rb
210
+ - lib/browserctl/replay/telemetry.rb
197
211
  - lib/browserctl/runner.rb
198
212
  - lib/browserctl/secret_resolver_registry.rb
199
213
  - lib/browserctl/secret_resolvers.rb
@@ -212,13 +226,28 @@ files:
212
226
  - lib/browserctl/server/handlers/observation.rb
213
227
  - lib/browserctl/server/handlers/page_lifecycle.rb
214
228
  - lib/browserctl/server/handlers/session.rb
229
+ - lib/browserctl/server/handlers/state.rb
215
230
  - lib/browserctl/server/handlers/storage.rb
216
231
  - lib/browserctl/server/idle_watcher.rb
217
232
  - lib/browserctl/server/page_session.rb
218
233
  - lib/browserctl/server/snapshot_builder.rb
219
234
  - lib/browserctl/session.rb
235
+ - lib/browserctl/snapshot/annotator.rb
236
+ - lib/browserctl/snapshot/extractor.rb
237
+ - lib/browserctl/snapshot/fingerprint.rb
238
+ - lib/browserctl/snapshot/ref.rb
239
+ - lib/browserctl/snapshot/serializer.rb
240
+ - lib/browserctl/state.rb
241
+ - lib/browserctl/state/bundle.rb
242
+ - lib/browserctl/state/transport.rb
243
+ - lib/browserctl/state/transports/file.rb
244
+ - lib/browserctl/state/transports/one_password.rb
245
+ - lib/browserctl/state/transports/s3.rb
220
246
  - lib/browserctl/version.rb
221
247
  - lib/browserctl/workflow.rb
248
+ - lib/browserctl/workflow/flow_wrapper.rb
249
+ - lib/browserctl/workflow/promoter.rb
250
+ - lib/browserctl/workflow/promotion_ledger.rb
222
251
  homepage: https://github.com/patrick204nqh/browserctl
223
252
  licenses:
224
253
  - MIT