browserctl 0.12.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 +17 -0
- data/README.md +3 -3
- data/bin/browserctl +39 -32
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +0 -27
- data/lib/browserctl/commands/cli_output.rb +17 -3
- 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 +56 -8
- 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 +40 -11
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- 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 +33 -294
- data/lib/browserctl/server/command_dispatcher.rb +25 -16
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +20 -47
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +61 -237
- metadata +11 -8
- 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/state.rb
CHANGED
|
@@ -42,6 +42,41 @@ module Browserctl
|
|
|
42
42
|
EXTENSION = ".bctl"
|
|
43
43
|
MANIFEST_VERSION = 1
|
|
44
44
|
|
|
45
|
+
# Value object bundling everything needed to persist a state bundle. The
|
|
46
|
+
# browser-side data lives in `cookies`, `local_storage`, and
|
|
47
|
+
# `session_storage`; the manifest extras live in `origins`, `flow`, and
|
|
48
|
+
# `flow_version`; `passphrase` flips the bundle into an encrypted variant.
|
|
49
|
+
Payload = Data.define(
|
|
50
|
+
:cookies,
|
|
51
|
+
:local_storage,
|
|
52
|
+
:session_storage,
|
|
53
|
+
:origins,
|
|
54
|
+
:flow,
|
|
55
|
+
:flow_version,
|
|
56
|
+
:passphrase
|
|
57
|
+
) do
|
|
58
|
+
def self.build(cookies: [], local_storage: {}, session_storage: {}, # rubocop:disable Metrics/ParameterLists
|
|
59
|
+
origins: nil, flow: nil, flow_version: nil, passphrase: nil)
|
|
60
|
+
new(
|
|
61
|
+
cookies: cookies,
|
|
62
|
+
local_storage: local_storage,
|
|
63
|
+
session_storage: session_storage,
|
|
64
|
+
origins: origins,
|
|
65
|
+
flow: flow,
|
|
66
|
+
flow_version: flow_version,
|
|
67
|
+
passphrase: passphrase
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_bundle_payload
|
|
72
|
+
{
|
|
73
|
+
cookies: cookies,
|
|
74
|
+
local_storage: local_storage,
|
|
75
|
+
session_storage: session_storage
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
45
80
|
def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
|
|
46
81
|
def self.exist?(name) = File.exist?(path(name))
|
|
47
82
|
|
|
@@ -51,22 +86,24 @@ module Browserctl
|
|
|
51
86
|
raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
52
87
|
end
|
|
53
88
|
|
|
54
|
-
# Persist a bundle. `payload` is
|
|
55
|
-
#
|
|
56
|
-
|
|
89
|
+
# Persist a bundle. `payload` is a `State::Payload` value object carrying
|
|
90
|
+
# cookies, local/session storage, and the manifest extras (origins, flow,
|
|
91
|
+
# flow_version, passphrase).
|
|
92
|
+
def self.save(name, payload)
|
|
57
93
|
validate_name!(name)
|
|
58
94
|
FileUtils.mkdir_p(BASE_DIR)
|
|
59
95
|
|
|
96
|
+
bundle_payload = payload.to_bundle_payload
|
|
60
97
|
manifest = build_manifest(
|
|
61
98
|
name: name,
|
|
62
|
-
origins: origins || derive_origins(
|
|
63
|
-
flow: flow,
|
|
64
|
-
flow_version: flow_version,
|
|
65
|
-
cookies: payload
|
|
66
|
-
encrypted: !passphrase.nil?
|
|
99
|
+
origins: payload.origins || derive_origins(bundle_payload),
|
|
100
|
+
flow: payload.flow,
|
|
101
|
+
flow_version: payload.flow_version,
|
|
102
|
+
cookies: payload.cookies || [],
|
|
103
|
+
encrypted: !payload.passphrase.nil?
|
|
67
104
|
)
|
|
68
105
|
|
|
69
|
-
blob = Bundle.encode(manifest: manifest, payload:
|
|
106
|
+
blob = Bundle.encode(manifest: manifest, payload: bundle_payload, passphrase: payload.passphrase)
|
|
70
107
|
File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
|
|
71
108
|
manifest
|
|
72
109
|
end
|
data/lib/browserctl/version.rb
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Workflow
|
|
5
|
+
# Owns the AUTH_REQUIRED recovery state machine for `load_state`.
|
|
6
|
+
#
|
|
7
|
+
# When the daemon reports AUTH_REQUIRED on a `state_load` (e.g. expired
|
|
8
|
+
# cookies in the bundle), the manager either runs the bound flow or
|
|
9
|
+
# the caller-provided override, re-saves the bundle, and reloads it
|
|
10
|
+
# with `skip_auth_check: true`.
|
|
11
|
+
#
|
|
12
|
+
# Decoupled from {ContextualPersistence} so the multi-step recovery
|
|
13
|
+
# logic has a dedicated home and a dedicated spec. The host context
|
|
14
|
+
# only needs to expose `client` (for daemon RPCs) and `invoke` (for
|
|
15
|
+
# running the bound flow); see {ContextualPersistence#load_state}.
|
|
16
|
+
class RecoveryManager
|
|
17
|
+
AUTH_REQUIRED_CODE = "AUTH_REQUIRED"
|
|
18
|
+
|
|
19
|
+
# @param context [#client, #invoke] a workflow context exposing
|
|
20
|
+
# the daemon client and an `invoke(flow_name, page:)` entry point.
|
|
21
|
+
def initialize(context)
|
|
22
|
+
@context = context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True when `res` is the daemon's AUTH_REQUIRED preflight signal.
|
|
26
|
+
def self.auth_required?(res)
|
|
27
|
+
(res[:code] || res["code"]) == AUTH_REQUIRED_CODE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run recovery for `state_name` given the daemon's initial AUTH_REQUIRED
|
|
31
|
+
# response. Returns the merged retry result (with `rotated: true`) or
|
|
32
|
+
# raises {WorkflowError} when no flow is bound and no override is
|
|
33
|
+
# supplied, or when the post-rotation reload still fails.
|
|
34
|
+
#
|
|
35
|
+
# @param state_name [String] the bundle name being loaded
|
|
36
|
+
# @param initial_res [Hash] the original AUTH_REQUIRED response
|
|
37
|
+
# @param on_auth_required [Proc, nil] optional override; when given,
|
|
38
|
+
# it runs in lieu of invoking the suggested flow.
|
|
39
|
+
def recover(state_name, initial_res, on_auth_required: nil)
|
|
40
|
+
if on_auth_required
|
|
41
|
+
on_auth_required.call
|
|
42
|
+
else
|
|
43
|
+
invoke_bound_flow(state_name, initial_res)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rotate_and_reload(state_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :context
|
|
52
|
+
|
|
53
|
+
def invoke_bound_flow(state_name, initial_res)
|
|
54
|
+
flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
|
|
55
|
+
if flow_name.nil? || flow_name.to_s.empty?
|
|
56
|
+
raise WorkflowError,
|
|
57
|
+
"state '#{state_name}' needs auth but bundle has no bound flow — " \
|
|
58
|
+
"save with `save_state('#{state_name}', flow: :NAME)` or pass on_auth_required:"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Match the daemon's `state load` preflight: it auth-checks the first
|
|
62
|
+
# open page (insertion order). Passing that same name to the flow
|
|
63
|
+
# gives stdlib flows a `page` proxy to drive (oauth_github reads
|
|
64
|
+
# `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
|
|
65
|
+
# page only when nothing is open — `state_save` would have errored
|
|
66
|
+
# earlier in that case, so this is a defence-in-depth nil.
|
|
67
|
+
context.invoke(flow_name, page: first_open_page)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rotate_and_reload(state_name)
|
|
71
|
+
after_save = context.client.state_save(state_name)
|
|
72
|
+
raise WorkflowError, after_save[:error] if after_save[:error]
|
|
73
|
+
|
|
74
|
+
retry_res = context.client.state_load(state_name, skip_auth_check: true)
|
|
75
|
+
raise WorkflowError, retry_res[:error] if retry_res[:error]
|
|
76
|
+
|
|
77
|
+
retry_res.merge(rotated: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def first_open_page
|
|
81
|
+
res = context.client.page_list
|
|
82
|
+
pages = res[:pages] || res["pages"] || []
|
|
83
|
+
pages.first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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
|
|
@@ -66,11 +65,15 @@ module Browserctl
|
|
|
66
65
|
version
|
|
67
66
|
end
|
|
68
67
|
|
|
69
|
-
|
|
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
|
|
70
72
|
StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
|
|
71
|
-
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
72
73
|
|
|
73
74
|
class WorkflowContext
|
|
75
|
+
include ContextualPersistence
|
|
76
|
+
|
|
74
77
|
attr_reader :client, :replay_context, :params
|
|
75
78
|
|
|
76
79
|
def initialize(params, client, replay_context: nil)
|
|
@@ -79,20 +82,6 @@ module Browserctl
|
|
|
79
82
|
@replay_context = replay_context
|
|
80
83
|
end
|
|
81
84
|
|
|
82
|
-
def store(key, value)
|
|
83
|
-
res = @client.store(key.to_s, value)
|
|
84
|
-
raise WorkflowError, res[:error] if res[:error]
|
|
85
|
-
|
|
86
|
-
value
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def fetch(key)
|
|
90
|
-
res = @client.fetch(key.to_s)
|
|
91
|
-
raise WorkflowError, res[:error] if res[:error]
|
|
92
|
-
|
|
93
|
-
res[:value]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
85
|
def method_missing(name, *args)
|
|
97
86
|
return @params[name] if @params.key?(name)
|
|
98
87
|
|
|
@@ -121,66 +110,6 @@ module Browserctl
|
|
|
121
110
|
res
|
|
122
111
|
end
|
|
123
112
|
|
|
124
|
-
def save_session(session_name, encrypt: false)
|
|
125
|
-
res = @client.session_save(session_name, encrypt: encrypt)
|
|
126
|
-
raise WorkflowError, res[:error] if res[:error]
|
|
127
|
-
|
|
128
|
-
res
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Persists the daemon's current cookies + storage as a .bctl bundle.
|
|
132
|
-
# Optional flow binding lets `load_state` auto-rotate when the bundle
|
|
133
|
-
# is detected as needing authentication.
|
|
134
|
-
def save_state(name, flow: nil, origins: nil, encrypt: false)
|
|
135
|
-
passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
|
|
136
|
-
res = @client.state_save(name.to_s,
|
|
137
|
-
flow: flow&.to_s, origins: origins, passphrase: passphrase)
|
|
138
|
-
raise WorkflowError, res[:error] if res[:error]
|
|
139
|
-
|
|
140
|
-
res
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
|
|
144
|
-
# applying (e.g. expired cookies in the payload), this rotates the bound
|
|
145
|
-
# flow and retries — no caller code change required.
|
|
146
|
-
#
|
|
147
|
-
# @param on_auth_required [Proc, nil] override the auto-rotate path. The
|
|
148
|
-
# block runs in the workflow context, in lieu of invoking the manifest's
|
|
149
|
-
# bound flow. Use this when the recovery procedure is bespoke.
|
|
150
|
-
def load_state(name, on_auth_required: nil)
|
|
151
|
-
res = @client.state_load(name.to_s)
|
|
152
|
-
return res unless auth_required_response?(res)
|
|
153
|
-
|
|
154
|
-
recover_auth_required_state(name.to_s, res, on_auth_required)
|
|
155
|
-
end
|
|
156
|
-
DEPRECATED_LOAD_SESSION_FALLBACK = <<~MSG
|
|
157
|
-
[browserctl] DEPRECATION: `load_session(name, fallback:, expired_if:)` is superseded by
|
|
158
|
-
`load_state(name)` with a flow-bound bundle (`save_state(name, flow: :name)`).
|
|
159
|
-
`load_session` will be removed in v0.12. See docs/concepts/state.md.
|
|
160
|
-
MSG
|
|
161
|
-
|
|
162
|
-
def load_session(session_name, fallback: nil, expired_if: nil)
|
|
163
|
-
warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
|
|
164
|
-
validate_expired_if!(expired_if)
|
|
165
|
-
fallback_name = fallback&.to_s
|
|
166
|
-
res = @client.session_load(session_name)
|
|
167
|
-
|
|
168
|
-
if res[:error]
|
|
169
|
-
raise WorkflowError, res[:error] unless fallback_name
|
|
170
|
-
|
|
171
|
-
invoke(fallback_name)
|
|
172
|
-
return load_after_fallback(session_name, fallback_name)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
return res if expired_if.nil? || !call_expired_if(expired_if, session_name)
|
|
176
|
-
|
|
177
|
-
recover_expired_session(session_name, fallback_name, expired_if)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def list_sessions
|
|
181
|
-
@client.session_list[:sessions]
|
|
182
|
-
end
|
|
183
|
-
|
|
184
113
|
def ask(prompt)
|
|
185
114
|
$stderr.print("[browserctl] #{prompt} ")
|
|
186
115
|
$stdin.gets.chomp
|
|
@@ -229,95 +158,6 @@ module Browserctl
|
|
|
229
158
|
|
|
230
159
|
private
|
|
231
160
|
|
|
232
|
-
def auth_required_response?(res)
|
|
233
|
-
(res[:code] || res["code"]) == "AUTH_REQUIRED"
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def recover_auth_required_state(name, initial_res, on_auth_required)
|
|
237
|
-
if on_auth_required
|
|
238
|
-
on_auth_required.call
|
|
239
|
-
else
|
|
240
|
-
flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
|
|
241
|
-
unless flow_name && !flow_name.to_s.empty?
|
|
242
|
-
raise WorkflowError,
|
|
243
|
-
"state '#{name}' needs auth but bundle has no bound flow — " \
|
|
244
|
-
"save with `save_state('#{name}', flow: :NAME)` or pass on_auth_required:"
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Match the daemon's `state load` preflight: it auth-checks the first
|
|
248
|
-
# open page (insertion order). Passing that same name to the flow
|
|
249
|
-
# gives stdlib flows a `page` proxy to drive (oauth_github reads
|
|
250
|
-
# `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
|
|
251
|
-
# page only when nothing is open — `state_save` would have errored
|
|
252
|
-
# earlier in that case, so this is a defence-in-depth nil.
|
|
253
|
-
invoke(flow_name, page: first_open_page)
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
after_save = @client.state_save(name)
|
|
257
|
-
raise WorkflowError, after_save[:error] if after_save[:error]
|
|
258
|
-
|
|
259
|
-
retry_res = @client.state_load(name, skip_auth_check: true)
|
|
260
|
-
raise WorkflowError, retry_res[:error] if retry_res[:error]
|
|
261
|
-
|
|
262
|
-
retry_res.merge(rotated: true)
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def first_open_page
|
|
266
|
-
res = @client.page_list
|
|
267
|
-
pages = res[:pages] || res["pages"] || []
|
|
268
|
-
pages.first
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def validate_expired_if!(expired_if)
|
|
272
|
-
return unless expired_if
|
|
273
|
-
|
|
274
|
-
unless expired_if.lambda?
|
|
275
|
-
raise ArgumentError,
|
|
276
|
-
"expired_if: must be a lambda (-> { }), not a Proc — " \
|
|
277
|
-
"bare return inside a Proc unwinds the caller"
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
return if expired_if.arity.zero?
|
|
281
|
-
|
|
282
|
-
raise ArgumentError,
|
|
283
|
-
"expired_if: lambda must take zero arguments (got #{expired_if.arity}) — " \
|
|
284
|
-
"use -> { page(:name).url... } to access pages via the workflow context"
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def call_expired_if(expired_if, session_name)
|
|
288
|
-
expired_if.call
|
|
289
|
-
rescue WorkflowError, StandardError => e
|
|
290
|
-
raise WorkflowError, "expired_if check failed for session '#{session_name}': #{e.message}"
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def recover_expired_session(session_name, fallback_name, expired_if)
|
|
294
|
-
unless fallback_name
|
|
295
|
-
raise WorkflowError,
|
|
296
|
-
"session '#{session_name}' is expired; provide fallback: to auto-recover"
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
invoke(fallback_name)
|
|
300
|
-
res = load_after_fallback(session_name, fallback_name)
|
|
301
|
-
|
|
302
|
-
if call_expired_if(expired_if, session_name)
|
|
303
|
-
raise WorkflowError,
|
|
304
|
-
"session '#{session_name}' still expired after running fallback '#{fallback_name}'"
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
res
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def load_after_fallback(session_name, fallback)
|
|
311
|
-
res = @client.session_load(session_name)
|
|
312
|
-
return res unless res[:error]
|
|
313
|
-
|
|
314
|
-
msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
|
|
315
|
-
unless Session.exist?(session_name)
|
|
316
|
-
msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
|
|
317
|
-
end
|
|
318
|
-
raise WorkflowError, msg
|
|
319
|
-
end
|
|
320
|
-
|
|
321
161
|
def invoke_stack
|
|
322
162
|
@invoke_stack ||= []
|
|
323
163
|
end
|
|
@@ -356,6 +196,21 @@ module Browserctl
|
|
|
356
196
|
class PageProxy
|
|
357
197
|
attr_accessor :replay_context
|
|
358
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
|
+
|
|
359
214
|
def initialize(name, client, replay_context: nil, matcher: nil)
|
|
360
215
|
@name = name
|
|
361
216
|
@client = client
|
|
@@ -363,7 +218,20 @@ module Browserctl
|
|
|
363
218
|
@matcher = matcher || Replay::FingerprintMatcher.new
|
|
364
219
|
end
|
|
365
220
|
|
|
366
|
-
|
|
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
|
|
367
235
|
|
|
368
236
|
def fill(selector = nil, value = nil, ref: nil)
|
|
369
237
|
with_selector_fallback(:fill, selector, ref) do |sel, r|
|
|
@@ -377,24 +245,6 @@ module Browserctl
|
|
|
377
245
|
end
|
|
378
246
|
end
|
|
379
247
|
|
|
380
|
-
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
381
|
-
def screenshot(**) = unwrap @client.screenshot(@name, **)
|
|
382
|
-
def wait(sel, timeout: 30) = unwrap @client.wait(@name, sel, timeout: timeout)
|
|
383
|
-
def delete_cookies = unwrap @client.delete_cookies(@name)
|
|
384
|
-
def devtools = @client.devtools(@name)[:devtools_url]
|
|
385
|
-
def url = @client.url(@name)[:url]
|
|
386
|
-
def evaluate(expr) = @client.evaluate(@name, expr)[:result]
|
|
387
|
-
|
|
388
|
-
def storage_get(key, store: "local")
|
|
389
|
-
@client.storage_get(@name, key, store: store)[:value]
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def storage_set(key, value, store: "local")
|
|
393
|
-
unwrap @client.storage_set(@name, key, value, store: store)
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def press(key) = unwrap @client.press(@name, key)
|
|
397
|
-
|
|
398
248
|
def hover(selector = nil, ref: nil)
|
|
399
249
|
with_selector_fallback(:hover, selector, ref) do |sel, r|
|
|
400
250
|
@client.hover(@name, sel, ref: r)
|
|
@@ -413,9 +263,6 @@ module Browserctl
|
|
|
413
263
|
end
|
|
414
264
|
end
|
|
415
265
|
|
|
416
|
-
def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
|
|
417
|
-
def dialog_dismiss = unwrap @client.dialog_dismiss(@name)
|
|
418
|
-
|
|
419
266
|
private
|
|
420
267
|
|
|
421
268
|
# Issues the wrapped command. If the daemon returns selector_not_found
|
|
@@ -461,32 +308,24 @@ module Browserctl
|
|
|
461
308
|
end
|
|
462
309
|
end
|
|
463
310
|
|
|
464
|
-
class WorkflowDefinition
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
def initialize(name)
|
|
468
|
-
@name = name
|
|
469
|
-
@description = nil
|
|
470
|
-
@param_defs = {}
|
|
471
|
-
@steps = []
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
def desc(text)
|
|
475
|
-
@description = text
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
479
|
-
secret = true if secret_ref
|
|
480
|
-
@param_defs[name] =
|
|
481
|
-
ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
def step(label, retry_count: 0, timeout: nil, &block)
|
|
485
|
-
@steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
311
|
+
class WorkflowDefinition < CallableDefinition
|
|
312
|
+
def callable_kind
|
|
313
|
+
:workflow
|
|
486
314
|
end
|
|
487
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`.
|
|
488
320
|
def compose(workflow_name)
|
|
489
|
-
|
|
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)
|
|
490
329
|
raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
|
|
491
330
|
|
|
492
331
|
@steps.concat(source.steps)
|
|
@@ -499,6 +338,14 @@ module Browserctl
|
|
|
499
338
|
|
|
500
339
|
private
|
|
501
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
|
+
|
|
502
349
|
def execute_steps(ctx)
|
|
503
350
|
@steps.map { |defn| run_step(ctx, defn) }.each do |r|
|
|
504
351
|
raise WorkflowError, "step '#{r.name}' failed: #{r.error}" unless r.ok
|
|
@@ -508,36 +355,13 @@ module Browserctl
|
|
|
508
355
|
def run_step(ctx, defn)
|
|
509
356
|
last_error = nil
|
|
510
357
|
(defn.retry_count + 1).times do
|
|
511
|
-
|
|
358
|
+
execute_step_block(ctx, defn)
|
|
512
359
|
return StepResult.new(name: defn.label, ok: true)
|
|
513
360
|
rescue StandardError => e
|
|
514
361
|
last_error = e
|
|
515
362
|
end
|
|
516
363
|
StepResult.new(name: defn.label, ok: false, error: last_error.message)
|
|
517
364
|
end
|
|
518
|
-
|
|
519
|
-
def execute_block(ctx, defn)
|
|
520
|
-
if defn.timeout
|
|
521
|
-
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
522
|
-
else
|
|
523
|
-
ctx.instance_exec(&defn.block)
|
|
524
|
-
end
|
|
525
|
-
rescue ::Timeout::Error
|
|
526
|
-
raise WorkflowError, "step '#{defn.label}' timed out after #{defn.timeout}s"
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
def resolve_params(provided)
|
|
530
|
-
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
531
|
-
val = if defn.secret_ref
|
|
532
|
-
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
533
|
-
else
|
|
534
|
-
provided[name] || defn.default
|
|
535
|
-
end
|
|
536
|
-
raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
|
|
537
|
-
|
|
538
|
-
out[name] = val
|
|
539
|
-
end
|
|
540
|
-
end
|
|
541
365
|
end
|
|
542
366
|
|
|
543
367
|
@registry_mutex = Mutex.new
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
@@ -138,7 +138,6 @@ files:
|
|
|
138
138
|
- bin/browserd
|
|
139
139
|
- bin/setup
|
|
140
140
|
- examples/cloudflare_hitl.rb
|
|
141
|
-
- examples/session_reuse.rb
|
|
142
141
|
- examples/test_automation_practices/advanced/ab_testing.rb
|
|
143
142
|
- examples/test_automation_practices/advanced/broken_images.rb
|
|
144
143
|
- examples/test_automation_practices/advanced/file_download.rb
|
|
@@ -165,6 +164,7 @@ files:
|
|
|
165
164
|
- examples/the_internet/dynamic_loading.rb
|
|
166
165
|
- examples/the_internet/login.rb
|
|
167
166
|
- lib/browserctl.rb
|
|
167
|
+
- lib/browserctl/callable_definition.rb
|
|
168
168
|
- lib/browserctl/client.rb
|
|
169
169
|
- lib/browserctl/commands/ask.rb
|
|
170
170
|
- lib/browserctl/commands/cli_output.rb
|
|
@@ -176,24 +176,24 @@ files:
|
|
|
176
176
|
- lib/browserctl/commands/flow.rb
|
|
177
177
|
- lib/browserctl/commands/init.rb
|
|
178
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
187
|
- lib/browserctl/commands/trace.rb
|
|
188
188
|
- lib/browserctl/commands/workflow.rb
|
|
189
189
|
- lib/browserctl/constants.rb
|
|
190
|
+
- lib/browserctl/contextual_persistence.rb
|
|
190
191
|
- lib/browserctl/crash_report.rb
|
|
191
192
|
- lib/browserctl/detectors.rb
|
|
192
193
|
- lib/browserctl/detectors/auth_required.rb
|
|
193
|
-
- lib/browserctl/driver.rb
|
|
194
|
-
- lib/browserctl/driver/base.rb
|
|
195
194
|
- lib/browserctl/driver/cdp.rb
|
|
196
195
|
- lib/browserctl/driver/cdp_page.rb
|
|
196
|
+
- lib/browserctl/encryption_service.rb
|
|
197
197
|
- lib/browserctl/error/codes.rb
|
|
198
198
|
- lib/browserctl/error/exit_codes.rb
|
|
199
199
|
- lib/browserctl/error/suggested_actions.rb
|
|
@@ -211,6 +211,10 @@ files:
|
|
|
211
211
|
- lib/browserctl/migrations.rb
|
|
212
212
|
- lib/browserctl/policy.rb
|
|
213
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
|
|
214
218
|
- lib/browserctl/redactor.rb
|
|
215
219
|
- lib/browserctl/replay/context.rb
|
|
216
220
|
- lib/browserctl/replay/fingerprint_matcher.rb
|
|
@@ -235,13 +239,11 @@ files:
|
|
|
235
239
|
- lib/browserctl/server/handlers/navigation.rb
|
|
236
240
|
- lib/browserctl/server/handlers/observation.rb
|
|
237
241
|
- lib/browserctl/server/handlers/page_lifecycle.rb
|
|
238
|
-
- lib/browserctl/server/handlers/session.rb
|
|
239
242
|
- lib/browserctl/server/handlers/state.rb
|
|
240
243
|
- lib/browserctl/server/handlers/storage.rb
|
|
241
244
|
- lib/browserctl/server/idle_watcher.rb
|
|
242
245
|
- lib/browserctl/server/page_session.rb
|
|
243
246
|
- lib/browserctl/server/snapshot_builder.rb
|
|
244
|
-
- lib/browserctl/session.rb
|
|
245
247
|
- lib/browserctl/snapshot/annotator.rb
|
|
246
248
|
- lib/browserctl/snapshot/extractor.rb
|
|
247
249
|
- lib/browserctl/snapshot/fingerprint.rb
|
|
@@ -258,6 +260,7 @@ files:
|
|
|
258
260
|
- lib/browserctl/workflow/flow_wrapper.rb
|
|
259
261
|
- lib/browserctl/workflow/promoter.rb
|
|
260
262
|
- lib/browserctl/workflow/promotion_ledger.rb
|
|
263
|
+
- lib/browserctl/workflow/recovery_manager.rb
|
|
261
264
|
homepage: https://github.com/patrick204nqh/browserctl
|
|
262
265
|
licenses:
|
|
263
266
|
- MIT
|