browserctl 0.9.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 +45 -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 +36 -0
  14. data/lib/browserctl/flow.rb +215 -0
  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 +32 -2
@@ -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.9.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,18 +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
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
193
204
  - lib/browserctl/logger.rb
194
205
  - lib/browserctl/policy.rb
195
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
196
211
  - lib/browserctl/runner.rb
197
212
  - lib/browserctl/secret_resolver_registry.rb
198
213
  - lib/browserctl/secret_resolvers.rb
@@ -211,13 +226,28 @@ files:
211
226
  - lib/browserctl/server/handlers/observation.rb
212
227
  - lib/browserctl/server/handlers/page_lifecycle.rb
213
228
  - lib/browserctl/server/handlers/session.rb
229
+ - lib/browserctl/server/handlers/state.rb
214
230
  - lib/browserctl/server/handlers/storage.rb
215
231
  - lib/browserctl/server/idle_watcher.rb
216
232
  - lib/browserctl/server/page_session.rb
217
233
  - lib/browserctl/server/snapshot_builder.rb
218
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
219
246
  - lib/browserctl/version.rb
220
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
221
251
  homepage: https://github.com/patrick204nqh/browserctl
222
252
  licenses:
223
253
  - MIT