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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +142 -0
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -1,21 +1,79 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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/
|
|
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.
|
|
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
|
data/examples/session_reuse.rb
DELETED
|
@@ -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
|