browserctl 0.11.0 → 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -1,21 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
3
+ require_relative "callable_definition"
4
4
  require_relative "client"
5
+ require_relative "contextual_persistence"
5
6
  require_relative "errors"
6
7
  require_relative "flow_registry"
7
8
  require_relative "replay/context"
8
9
  require_relative "replay/fingerprint_matcher"
9
10
  require_relative "replay/snapshot_diff"
10
- require_relative "secret_resolvers"
11
- require_relative "session"
12
11
 
13
12
  module Browserctl
14
- ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
13
+ # Workflow-file format version. Workflows are Ruby files; the schema gate
14
+ # is a top-of-file comment header:
15
+ #
16
+ # # format_version: 1
17
+ #
18
+ # Unlike bundles and recordings, an unsupported or missing version on a
19
+ # workflow file is a *warning*, not a hard failure. Workflows are
20
+ # human-authored Ruby — the loader prefers to surface drift via stderr
21
+ # and let the file run, rather than block execution. See
22
+ # docs/reference/format-versions.md.
23
+ WORKFLOW_FORMAT_VERSION = 1
24
+ SUPPORTED_WORKFLOW_FORMAT_VERSIONS = [WORKFLOW_FORMAT_VERSION].freeze
25
+
26
+ # Matches a leading-line comment of the form `# format_version: <int>`.
27
+ # Tolerates leading whitespace inside the comment body and ignores the
28
+ # `# frozen_string_literal: true` magic comment that conventionally
29
+ # precedes it.
30
+ WORKFLOW_FORMAT_VERSION_HEADER = /^\s*#\s*format_version:\s*(\d+)\s*$/
31
+
32
+ # Parses the `# format_version: N` header from a workflow file's source.
33
+ # Scans only the contiguous leading comment block (and blank lines) so
34
+ # the header cannot be smuggled in mid-file. Returns the integer if
35
+ # present, or nil if the file has no version header.
36
+ def self.parse_workflow_format_version(source)
37
+ source.each_line do |line|
38
+ stripped = line.strip
39
+ next if stripped.empty?
40
+ break unless stripped.start_with?("#")
41
+
42
+ if (m = line.match(WORKFLOW_FORMAT_VERSION_HEADER))
43
+ return Integer(m[1])
44
+ end
45
+ end
46
+ nil
47
+ end
48
+
49
+ # Reads a workflow file and warns to stderr when the `format_version:`
50
+ # header is missing or declares an unsupported version. Always returns
51
+ # the parsed integer (or nil) — never raises. Callers should still
52
+ # `load` the file regardless.
53
+ def self.verify_workflow_format_version!(path)
54
+ source = File.read(path)
55
+ version = parse_workflow_format_version(source)
56
+
57
+ if version.nil?
58
+ warn "[browserctl] workflow #{path} is missing a `# format_version: N` header " \
59
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
60
+ elsif !SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
61
+ warn "[browserctl] workflow #{path} format_version=#{version} is not supported " \
62
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
63
+ end
64
+
65
+ version
66
+ end
67
+
68
+ # Back-compat aliases — exposed in the public surface (flow_wrapper specs,
69
+ # workflow specs reference these directly).
70
+ ParamDef = CallableDefinition::ParamDef
71
+ StepDef = CallableDefinition::StepDef
15
72
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
16
- StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
17
73
 
18
74
  class WorkflowContext
75
+ include ContextualPersistence
76
+
19
77
  attr_reader :client, :replay_context, :params
20
78
 
21
79
  def initialize(params, client, replay_context: nil)
@@ -24,20 +82,6 @@ module Browserctl
24
82
  @replay_context = replay_context
25
83
  end
26
84
 
27
- def store(key, value)
28
- res = @client.store(key.to_s, value)
29
- raise WorkflowError, res[:error] if res[:error]
30
-
31
- value
32
- end
33
-
34
- def fetch(key)
35
- res = @client.fetch(key.to_s)
36
- raise WorkflowError, res[:error] if res[:error]
37
-
38
- res[:value]
39
- end
40
-
41
85
  def method_missing(name, *args)
42
86
  return @params[name] if @params.key?(name)
43
87
 
@@ -66,66 +110,6 @@ module Browserctl
66
110
  res
67
111
  end
68
112
 
69
- def save_session(session_name, encrypt: false)
70
- res = @client.session_save(session_name, encrypt: encrypt)
71
- raise WorkflowError, res[:error] if res[:error]
72
-
73
- res
74
- end
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
-
107
- def load_session(session_name, fallback: nil, expired_if: nil)
108
- warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
109
- validate_expired_if!(expired_if)
110
- fallback_name = fallback&.to_s
111
- res = @client.session_load(session_name)
112
-
113
- if res[:error]
114
- raise WorkflowError, res[:error] unless fallback_name
115
-
116
- invoke(fallback_name)
117
- return load_after_fallback(session_name, fallback_name)
118
- end
119
-
120
- return res if expired_if.nil? || !call_expired_if(expired_if, session_name)
121
-
122
- recover_expired_session(session_name, fallback_name, expired_if)
123
- end
124
-
125
- def list_sessions
126
- @client.session_list[:sessions]
127
- end
128
-
129
113
  def ask(prompt)
130
114
  $stderr.print("[browserctl] #{prompt} ")
131
115
  $stdin.gets.chomp
@@ -174,95 +158,6 @@ module Browserctl
174
158
 
175
159
  private
176
160
 
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
-
216
- def validate_expired_if!(expired_if)
217
- return unless expired_if
218
-
219
- unless expired_if.lambda?
220
- raise ArgumentError,
221
- "expired_if: must be a lambda (-> { }), not a Proc — " \
222
- "bare return inside a Proc unwinds the caller"
223
- end
224
-
225
- return if expired_if.arity.zero?
226
-
227
- raise ArgumentError,
228
- "expired_if: lambda must take zero arguments (got #{expired_if.arity}) — " \
229
- "use -> { page(:name).url... } to access pages via the workflow context"
230
- end
231
-
232
- def call_expired_if(expired_if, session_name)
233
- expired_if.call
234
- rescue WorkflowError, StandardError => e
235
- raise WorkflowError, "expired_if check failed for session '#{session_name}': #{e.message}"
236
- end
237
-
238
- def recover_expired_session(session_name, fallback_name, expired_if)
239
- unless fallback_name
240
- raise WorkflowError,
241
- "session '#{session_name}' is expired; provide fallback: to auto-recover"
242
- end
243
-
244
- invoke(fallback_name)
245
- res = load_after_fallback(session_name, fallback_name)
246
-
247
- if call_expired_if(expired_if, session_name)
248
- raise WorkflowError,
249
- "session '#{session_name}' still expired after running fallback '#{fallback_name}'"
250
- end
251
-
252
- res
253
- end
254
-
255
- def load_after_fallback(session_name, fallback)
256
- res = @client.session_load(session_name)
257
- return res unless res[:error]
258
-
259
- msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
260
- unless Session.exist?(session_name)
261
- msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
262
- end
263
- raise WorkflowError, msg
264
- end
265
-
266
161
  def invoke_stack
267
162
  @invoke_stack ||= []
268
163
  end
@@ -301,6 +196,21 @@ module Browserctl
301
196
  class PageProxy
302
197
  attr_accessor :replay_context
303
198
 
199
+ # Declarative wrapper for `unwrap @client.METHOD(@name, ...)` one-liners.
200
+ # Forwards positional + keyword args verbatim. Pass `extract:` to return
201
+ # a single key from the client response instead of unwrapping.
202
+ def self.delegate_unwrap(method_name, extract: nil)
203
+ if extract
204
+ define_method(method_name) do |*args, **kwargs|
205
+ @client.public_send(method_name, @name, *args, **kwargs)[extract]
206
+ end
207
+ else
208
+ define_method(method_name) do |*args, **kwargs|
209
+ unwrap @client.public_send(method_name, @name, *args, **kwargs)
210
+ end
211
+ end
212
+ end
213
+
304
214
  def initialize(name, client, replay_context: nil, matcher: nil)
305
215
  @name = name
306
216
  @client = client
@@ -308,7 +218,20 @@ module Browserctl
308
218
  @matcher = matcher || Replay::FingerprintMatcher.new
309
219
  end
310
220
 
311
- def navigate(url) = unwrap @client.navigate(@name, url)
221
+ delegate_unwrap :navigate
222
+ delegate_unwrap :snapshot
223
+ delegate_unwrap :screenshot
224
+ delegate_unwrap :wait
225
+ delegate_unwrap :delete_cookies
226
+ delegate_unwrap :press
227
+ delegate_unwrap :storage_set
228
+ delegate_unwrap :dialog_accept
229
+ delegate_unwrap :dialog_dismiss
230
+
231
+ delegate_unwrap :devtools, extract: :devtools_url
232
+ delegate_unwrap :url, extract: :url
233
+ delegate_unwrap :evaluate, extract: :result
234
+ delegate_unwrap :storage_get, extract: :value
312
235
 
313
236
  def fill(selector = nil, value = nil, ref: nil)
314
237
  with_selector_fallback(:fill, selector, ref) do |sel, r|
@@ -322,24 +245,6 @@ module Browserctl
322
245
  end
323
246
  end
324
247
 
325
- def snapshot(**) = unwrap @client.snapshot(@name, **)
326
- def screenshot(**) = unwrap @client.screenshot(@name, **)
327
- def wait(sel, timeout: 30) = unwrap @client.wait(@name, sel, timeout: timeout)
328
- def delete_cookies = unwrap @client.delete_cookies(@name)
329
- def devtools = @client.devtools(@name)[:devtools_url]
330
- def url = @client.url(@name)[:url]
331
- def evaluate(expr) = @client.evaluate(@name, expr)[:result]
332
-
333
- def storage_get(key, store: "local")
334
- @client.storage_get(@name, key, store: store)[:value]
335
- end
336
-
337
- def storage_set(key, value, store: "local")
338
- unwrap @client.storage_set(@name, key, value, store: store)
339
- end
340
-
341
- def press(key) = unwrap @client.press(@name, key)
342
-
343
248
  def hover(selector = nil, ref: nil)
344
249
  with_selector_fallback(:hover, selector, ref) do |sel, r|
345
250
  @client.hover(@name, sel, ref: r)
@@ -358,9 +263,6 @@ module Browserctl
358
263
  end
359
264
  end
360
265
 
361
- def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
362
- def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
363
-
364
266
  private
365
267
 
366
268
  # Issues the wrapped command. If the daemon returns selector_not_found
@@ -391,7 +293,7 @@ module Browserctl
391
293
  end
392
294
 
393
295
  def selector_not_found?(res)
394
- res.is_a?(Hash) && res[:code] == "selector_not_found"
296
+ res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
395
297
  end
396
298
 
397
299
  def log_rematch(cmd, selector, match)
@@ -406,32 +308,24 @@ module Browserctl
406
308
  end
407
309
  end
408
310
 
409
- class WorkflowDefinition
410
- attr_reader :name, :description, :param_defs, :steps
411
-
412
- def initialize(name)
413
- @name = name
414
- @description = nil
415
- @param_defs = {}
416
- @steps = []
417
- end
418
-
419
- def desc(text)
420
- @description = text
421
- end
422
-
423
- def param(name, required: false, secret: false, default: nil, secret_ref: nil)
424
- secret = true if secret_ref
425
- @param_defs[name] =
426
- ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
427
- end
428
-
429
- def step(label, retry_count: 0, timeout: nil, &block)
430
- @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
311
+ class WorkflowDefinition < CallableDefinition
312
+ def callable_kind
313
+ :workflow
431
314
  end
432
315
 
316
+ # Definition-time guard: composing a flow into a workflow would copy
317
+ # flow steps that may close over `page` (a flow-only DSL) into a
318
+ # context that doesn't expose it. Cross-kind composition is rejected
319
+ # here rather than failing later inside an `instance_exec`.
433
320
  def compose(workflow_name)
434
- source = Browserctl.lookup_workflow(workflow_name.to_s)
321
+ name = workflow_name.to_s
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"
326
+ end
327
+
328
+ source = Browserctl.lookup_workflow(name)
435
329
  raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
436
330
 
437
331
  @steps.concat(source.steps)
@@ -444,6 +338,14 @@ module Browserctl
444
338
 
445
339
  private
446
340
 
341
+ def missing_param_error(name)
342
+ WorkflowError.new("required param '#{name}' missing")
343
+ end
344
+
345
+ def step_timeout_error(defn)
346
+ WorkflowError.new("step '#{defn.label}' timed out after #{defn.timeout}s")
347
+ end
348
+
447
349
  def execute_steps(ctx)
448
350
  @steps.map { |defn| run_step(ctx, defn) }.each do |r|
449
351
  raise WorkflowError, "step '#{r.name}' failed: #{r.error}" unless r.ok
@@ -453,36 +355,13 @@ module Browserctl
453
355
  def run_step(ctx, defn)
454
356
  last_error = nil
455
357
  (defn.retry_count + 1).times do
456
- execute_block(ctx, defn)
358
+ execute_step_block(ctx, defn)
457
359
  return StepResult.new(name: defn.label, ok: true)
458
360
  rescue StandardError => e
459
361
  last_error = e
460
362
  end
461
363
  StepResult.new(name: defn.label, ok: false, error: last_error.message)
462
364
  end
463
-
464
- def execute_block(ctx, defn)
465
- if defn.timeout
466
- ::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
467
- else
468
- ctx.instance_exec(&defn.block)
469
- end
470
- rescue ::Timeout::Error
471
- raise WorkflowError, "step '#{defn.label}' timed out after #{defn.timeout}s"
472
- end
473
-
474
- def resolve_params(provided)
475
- @param_defs.each_with_object({}) do |(name, defn), out|
476
- val = if defn.secret_ref
477
- SecretResolverRegistry.resolve(defn.secret_ref)
478
- else
479
- provided[name] || defn.default
480
- end
481
- raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
482
-
483
- out[name] = val
484
- end
485
- end
486
365
  end
487
366
 
488
367
  @registry_mutex = Mutex.new
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: ferrum
@@ -127,8 +126,8 @@ description: Named browser sessions, Ruby workflow DSL, and a token-efficient DO
127
126
  email:
128
127
  - patrick204nqh@gmail.com
129
128
  executables:
130
- - browserd
131
129
  - browserctl
130
+ - browserd
132
131
  extensions: []
133
132
  extra_rdoc_files: []
134
133
  files:
@@ -139,7 +138,6 @@ files:
139
138
  - bin/browserd
140
139
  - bin/setup
141
140
  - examples/cloudflare_hitl.rb
142
- - examples/session_reuse.rb
143
141
  - examples/test_automation_practices/advanced/ab_testing.rb
144
142
  - examples/test_automation_practices/advanced/broken_images.rb
145
143
  - examples/test_automation_practices/advanced/file_download.rb
@@ -166,6 +164,7 @@ files:
166
164
  - examples/the_internet/dynamic_loading.rb
167
165
  - examples/the_internet/login.rb
168
166
  - lib/browserctl.rb
167
+ - lib/browserctl/callable_definition.rb
169
168
  - lib/browserctl/client.rb
170
169
  - lib/browserctl/commands/ask.rb
171
170
  - lib/browserctl/commands/cli_output.rb
@@ -176,22 +175,28 @@ files:
176
175
  - lib/browserctl/commands/fill.rb
177
176
  - lib/browserctl/commands/flow.rb
178
177
  - lib/browserctl/commands/init.rb
178
+ - lib/browserctl/commands/migrate.rb
179
+ - lib/browserctl/commands/output_format.rb
179
180
  - lib/browserctl/commands/page.rb
180
- - lib/browserctl/commands/record.rb
181
+ - lib/browserctl/commands/recording.rb
181
182
  - lib/browserctl/commands/resume.rb
182
183
  - lib/browserctl/commands/screenshot.rb
183
- - lib/browserctl/commands/session.rb
184
184
  - lib/browserctl/commands/snapshot.rb
185
185
  - lib/browserctl/commands/state.rb
186
186
  - lib/browserctl/commands/storage.rb
187
+ - lib/browserctl/commands/trace.rb
187
188
  - lib/browserctl/commands/workflow.rb
188
189
  - lib/browserctl/constants.rb
190
+ - lib/browserctl/contextual_persistence.rb
191
+ - lib/browserctl/crash_report.rb
189
192
  - lib/browserctl/detectors.rb
190
193
  - lib/browserctl/detectors/auth_required.rb
191
- - lib/browserctl/driver.rb
192
- - lib/browserctl/driver/base.rb
193
194
  - lib/browserctl/driver/cdp.rb
194
195
  - lib/browserctl/driver/cdp_page.rb
196
+ - lib/browserctl/encryption_service.rb
197
+ - lib/browserctl/error/codes.rb
198
+ - lib/browserctl/error/exit_codes.rb
199
+ - lib/browserctl/error/suggested_actions.rb
195
200
  - lib/browserctl/errors.rb
196
201
  - lib/browserctl/flow.rb
197
202
  - lib/browserctl/flow_registry.rb
@@ -201,13 +206,21 @@ files:
201
206
  - lib/browserctl/flows/stdlib/oauth_github.rb
202
207
  - lib/browserctl/flows/stdlib/oauth_google.rb
203
208
  - lib/browserctl/flows/stdlib/totp_2fa.rb
209
+ - lib/browserctl/format_version.rb
204
210
  - lib/browserctl/logger.rb
211
+ - lib/browserctl/migrations.rb
205
212
  - lib/browserctl/policy.rb
206
213
  - lib/browserctl/recording.rb
214
+ - lib/browserctl/recording/log_writer.rb
215
+ - lib/browserctl/recording/redactor.rb
216
+ - lib/browserctl/recording/state.rb
217
+ - lib/browserctl/recording/workflow_renderer.rb
218
+ - lib/browserctl/redactor.rb
207
219
  - lib/browserctl/replay/context.rb
208
220
  - lib/browserctl/replay/fingerprint_matcher.rb
209
221
  - lib/browserctl/replay/snapshot_diff.rb
210
222
  - lib/browserctl/replay/telemetry.rb
223
+ - lib/browserctl/rubocop/cops/typed_error.rb
211
224
  - lib/browserctl/runner.rb
212
225
  - lib/browserctl/secret_resolver_registry.rb
213
226
  - lib/browserctl/secret_resolvers.rb
@@ -220,18 +233,17 @@ files:
220
233
  - lib/browserctl/server/handlers/cookies.rb
221
234
  - lib/browserctl/server/handlers/daemon_control.rb
222
235
  - lib/browserctl/server/handlers/devtools.rb
236
+ - lib/browserctl/server/handlers/error_payload.rb
223
237
  - lib/browserctl/server/handlers/hitl.rb
224
238
  - lib/browserctl/server/handlers/interaction.rb
225
239
  - lib/browserctl/server/handlers/navigation.rb
226
240
  - lib/browserctl/server/handlers/observation.rb
227
241
  - lib/browserctl/server/handlers/page_lifecycle.rb
228
- - lib/browserctl/server/handlers/session.rb
229
242
  - lib/browserctl/server/handlers/state.rb
230
243
  - lib/browserctl/server/handlers/storage.rb
231
244
  - lib/browserctl/server/idle_watcher.rb
232
245
  - lib/browserctl/server/page_session.rb
233
246
  - lib/browserctl/server/snapshot_builder.rb
234
- - lib/browserctl/session.rb
235
247
  - lib/browserctl/snapshot/annotator.rb
236
248
  - lib/browserctl/snapshot/extractor.rb
237
249
  - lib/browserctl/snapshot/fingerprint.rb
@@ -248,6 +260,7 @@ files:
248
260
  - lib/browserctl/workflow/flow_wrapper.rb
249
261
  - lib/browserctl/workflow/promoter.rb
250
262
  - lib/browserctl/workflow/promotion_ledger.rb
263
+ - lib/browserctl/workflow/recovery_manager.rb
251
264
  homepage: https://github.com/patrick204nqh/browserctl
252
265
  licenses:
253
266
  - MIT
@@ -258,7 +271,6 @@ metadata:
258
271
  bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
259
272
  documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
260
273
  rubygems_mfa_required: 'true'
261
- post_install_message:
262
274
  rdoc_options: []
263
275
  require_paths:
264
276
  - lib
@@ -273,8 +285,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
273
285
  - !ruby/object:Gem::Version
274
286
  version: '0'
275
287
  requirements: []
276
- rubygems_version: 3.5.22
277
- signing_key:
288
+ rubygems_version: 3.6.9
278
289
  specification_version: 4
279
290
  summary: Persistent browser automation daemon and CLI for AI agents and developer
280
291
  workflows
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Demonstrates the authenticate-once, reuse-forever pattern.
4
- #
5
- # The first run (no saved session) invokes `login_once` to authenticate,
6
- # which saves the session. Every subsequent run loads the saved session
7
- # directly — no re-authentication needed.
8
- #
9
- # `expired_if:` detects when the saved session exists but server-side auth
10
- # has lapsed (rotated cookie, token TTL), and automatically re-authenticates.
11
- #
12
- # Run:
13
- # browserctl workflow run examples/session_reuse.rb \
14
- # --app_url https://the-internet.herokuapp.com \
15
- # --username tomsmith \
16
- # --password "SuperSecretPassword!"
17
- #
18
- # On the first run: authenticates and saves the session.
19
- # On subsequent runs: loads the session and skips the login page entirely.
20
-
21
- # --- Step 1: define the login workflow (run once, triggered automatically on missing/expired session) ---
22
-
23
- Browserctl.workflow "session_reuse/login_once" do
24
- desc "Authenticate and save session — called automatically by session_reuse when needed"
25
-
26
- param :app_url, required: true
27
- param :username, required: true
28
- param :password, required: true, secret: true
29
-
30
- step "open login page" do
31
- open_page(:main, url: "#{app_url}/login")
32
- end
33
-
34
- step "fill and submit credentials" do
35
- page(:main).fill("input#username", username)
36
- page(:main).fill("input#password", password)
37
- page(:main).click("button[type=submit]")
38
- end
39
-
40
- step "verify login succeeded" do
41
- assert page(:main).url.include?("/secure"), "login failed — still on login page"
42
- end
43
-
44
- step "save authenticated session" do
45
- save_session("session_reuse_demo")
46
- puts " ✓ Session saved — future runs will skip this step"
47
- end
48
- end
49
-
50
- # --- Step 2: the main workflow that reuses the saved session ---
51
-
52
- Browserctl.workflow "session_reuse" do
53
- desc "Authenticate once, reuse forever — demonstrates load_session with fallback and expired_if"
54
-
55
- param :app_url, default: "https://the-internet.herokuapp.com"
56
- param :username, default: "tomsmith"
57
- param :password, default: "SuperSecretPassword!", secret: true
58
-
59
- step "restore session or log in" do
60
- load_session("session_reuse_demo",
61
- fallback: "session_reuse/login_once",
62
- expired_if: lambda {
63
- page(:main).navigate("#{app_url}/secure")
64
- !page(:main).url.include?("/secure")
65
- })
66
- puts " ✓ Session ready — authenticated as #{username}"
67
- end
68
-
69
- step "do authenticated work" do
70
- page(:main).navigate("#{app_url}/secure")
71
- heading = page(:main).evaluate("document.querySelector('h2')?.textContent?.trim()")
72
- assert heading&.include?("Secure Area"), "expected to be in secure area, got: #{heading.inspect}"
73
- puts " ✓ Landed in secure area without re-authenticating"
74
- end
75
- end