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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
# Cryptographic primitives for browserctl-managed payloads.
|
|
8
|
+
#
|
|
9
|
+
# Raised when decryption fails (tampered ciphertext or wrong key). Callers
|
|
10
|
+
# should catch this rather than `OpenSSL::Cipher::CipherError` so they
|
|
11
|
+
# don't have to take a direct dependency on the underlying cipher library.
|
|
12
|
+
#
|
|
13
|
+
# Owns AES-256-GCM cipher setup and PBKDF2 key derivation so callers like
|
|
14
|
+
# `State::Bundle` don't reach into `OpenSSL::Cipher` directly. The contract
|
|
15
|
+
# is intentionally narrow:
|
|
16
|
+
#
|
|
17
|
+
# * `derive_keys(passphrase, salt)` returns a `[enc_key, hmac_key]` pair
|
|
18
|
+
# derived via PBKDF2-HMAC-SHA256 with `PBKDF2_ITERS` iterations and a
|
|
19
|
+
# 64-byte output split in half. The first half is the AES-256-GCM key,
|
|
20
|
+
# the second is the HMAC-SHA-256 key for the bundle footer.
|
|
21
|
+
# * `encrypt(plaintext, key)` returns `nonce || ciphertext || tag`.
|
|
22
|
+
# * `decrypt(blob, key)` reverses it; raises `OpenSSL::Cipher::CipherError`
|
|
23
|
+
# on tampered blobs / wrong key.
|
|
24
|
+
# * `random_salt` / `random_nonce` are exposed so callers can keep bundle
|
|
25
|
+
# wire-format assembly in one place without re-deriving sizes.
|
|
26
|
+
#
|
|
27
|
+
# The constants here are duplicated in `State::Bundle` only as named
|
|
28
|
+
# references to the wire format positions; the source of truth lives here.
|
|
29
|
+
module EncryptionService
|
|
30
|
+
class DecryptionError < StandardError; end
|
|
31
|
+
|
|
32
|
+
SALT_SIZE = 16
|
|
33
|
+
NONCE_SIZE = 12
|
|
34
|
+
TAG_SIZE = 16
|
|
35
|
+
KEY_SIZE = 32
|
|
36
|
+
PBKDF2_ITERS = 200_000
|
|
37
|
+
DIGEST = "SHA256"
|
|
38
|
+
CIPHER = "aes-256-gcm"
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Returns `[enc_key, hmac_key]`, each `KEY_SIZE` bytes.
|
|
43
|
+
def derive_keys(passphrase, salt)
|
|
44
|
+
material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, KEY_SIZE * 2, DIGEST)
|
|
45
|
+
[material.byteslice(0, KEY_SIZE), material.byteslice(KEY_SIZE, KEY_SIZE)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# AES-256-GCM. Returns `nonce || ciphertext || tag`.
|
|
49
|
+
def encrypt(plaintext, key)
|
|
50
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
51
|
+
cipher.encrypt
|
|
52
|
+
cipher.key = key
|
|
53
|
+
nonce = SecureRandom.bytes(NONCE_SIZE)
|
|
54
|
+
cipher.iv = nonce
|
|
55
|
+
ct = cipher.update(plaintext) + cipher.final
|
|
56
|
+
nonce + ct + cipher.auth_tag
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Inverse of `encrypt`. Raises `DecryptionError` on tampered ciphertext
|
|
60
|
+
# or wrong key so callers don't need to reach into `OpenSSL::Cipher`.
|
|
61
|
+
def decrypt(blob, key)
|
|
62
|
+
nonce = blob.byteslice(0, NONCE_SIZE)
|
|
63
|
+
tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
|
|
64
|
+
ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
|
|
65
|
+
|
|
66
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
|
67
|
+
cipher.decrypt
|
|
68
|
+
cipher.key = key
|
|
69
|
+
cipher.iv = nonce
|
|
70
|
+
cipher.auth_tag = tag
|
|
71
|
+
cipher.update(ciphertext) + cipher.final
|
|
72
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
73
|
+
raise DecryptionError, e.message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def random_salt
|
|
77
|
+
SecureRandom.bytes(SALT_SIZE)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def random_nonce
|
|
81
|
+
SecureRandom.bytes(NONCE_SIZE)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/browserctl/flow.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "callable_definition"
|
|
4
4
|
require_relative "errors"
|
|
5
|
-
require_relative "secret_resolvers"
|
|
6
5
|
|
|
7
6
|
module Browserctl
|
|
8
|
-
FlowParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
9
|
-
FlowStepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
10
7
|
FlowConditionDef = Struct.new(:kind, :label, :block, keyword_init: true)
|
|
11
8
|
|
|
9
|
+
# Back-compat aliases — flow_wrapper specs reference these directly.
|
|
10
|
+
FlowParamDef = CallableDefinition::ParamDef
|
|
11
|
+
FlowStepDef = CallableDefinition::StepDef
|
|
12
|
+
|
|
12
13
|
class FlowContext
|
|
13
14
|
attr_reader :page, :client, :params
|
|
14
15
|
|
|
@@ -29,31 +30,28 @@ module Browserctl
|
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
class Flow
|
|
33
|
+
class Flow < CallableDefinition
|
|
33
34
|
SEMVER_RE = /\A\d+\.\d+\.\d+\z/
|
|
34
35
|
|
|
35
|
-
attr_reader :
|
|
36
|
-
:version_string,
|
|
37
|
-
:description,
|
|
38
|
-
:param_defs,
|
|
39
|
-
:steps,
|
|
36
|
+
attr_reader :version_string,
|
|
40
37
|
:preconditions,
|
|
41
38
|
:postconditions,
|
|
42
39
|
:produces_state_block,
|
|
43
40
|
:min_browserctl_version
|
|
44
41
|
|
|
45
42
|
def initialize(name)
|
|
46
|
-
|
|
43
|
+
super
|
|
47
44
|
@version_string = "0.0.0"
|
|
48
|
-
@description = nil
|
|
49
|
-
@param_defs = {}
|
|
50
|
-
@steps = []
|
|
51
45
|
@preconditions = []
|
|
52
46
|
@postconditions = []
|
|
53
47
|
@produces_state_block = nil
|
|
54
48
|
@min_browserctl_version = nil
|
|
55
49
|
end
|
|
56
50
|
|
|
51
|
+
def callable_kind
|
|
52
|
+
:flow
|
|
53
|
+
end
|
|
54
|
+
|
|
57
55
|
def version(value)
|
|
58
56
|
validate_semver!(value, label: "version")
|
|
59
57
|
@version_string = value.to_s
|
|
@@ -64,27 +62,6 @@ module Browserctl
|
|
|
64
62
|
@min_browserctl_version = value.to_s
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def desc(text)
|
|
68
|
-
@description = text.to_s
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
72
|
-
secret = true if secret_ref
|
|
73
|
-
@param_defs[name] = FlowParamDef.new(
|
|
74
|
-
name: name,
|
|
75
|
-
required: required,
|
|
76
|
-
secret: secret,
|
|
77
|
-
default: default,
|
|
78
|
-
secret_ref: secret_ref
|
|
79
|
-
)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def step(label, retry_count: 0, timeout: nil, &block)
|
|
83
|
-
raise ArgumentError, "flow step '#{label}' requires a block" unless block
|
|
84
|
-
|
|
85
|
-
@steps << FlowStepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
65
|
def precondition(label = "precondition", &block)
|
|
89
66
|
raise ArgumentError, "precondition '#{label}' requires a block" unless block
|
|
90
67
|
|
|
@@ -103,6 +80,24 @@ module Browserctl
|
|
|
103
80
|
@produces_state_block = block
|
|
104
81
|
end
|
|
105
82
|
|
|
83
|
+
# Definition-time guard against cross-type composition. A flow may only
|
|
84
|
+
# compose other flows; pulling steps from a workflow would smuggle
|
|
85
|
+
# `store`/`fetch` into a flow context that has no daemon-backed
|
|
86
|
+
# persistence.
|
|
87
|
+
def compose(target_name)
|
|
88
|
+
name = target_name.to_s
|
|
89
|
+
if Browserctl.respond_to?(:lookup_workflow) && Browserctl.lookup_workflow(name)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
|
|
92
|
+
"workflows share state — composition across kinds is not supported"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
source = Browserctl.lookup_flow(name)
|
|
96
|
+
raise ArgumentError, "flow '#{name}' not found for composition" unless source
|
|
97
|
+
|
|
98
|
+
@steps.concat(source.steps)
|
|
99
|
+
end
|
|
100
|
+
|
|
106
101
|
def run(page: nil, client: nil, **params)
|
|
107
102
|
ctx = FlowContext.new(page: page, client: client, params: resolve_params(params))
|
|
108
103
|
|
|
@@ -121,20 +116,12 @@ module Browserctl
|
|
|
121
116
|
raise ArgumentError, "#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})"
|
|
122
117
|
end
|
|
123
118
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
128
|
-
elsif provided.key?(name)
|
|
129
|
-
provided[name]
|
|
130
|
-
else
|
|
131
|
-
defn.default
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
raise FlowParamError, "flow '#{@name}' requires param '#{name}'" if defn.required && val.nil?
|
|
119
|
+
def missing_param_error(name)
|
|
120
|
+
FlowParamError.new("flow '#{@name}' requires param '#{name}'")
|
|
121
|
+
end
|
|
135
122
|
|
|
136
|
-
|
|
137
|
-
|
|
123
|
+
def step_timeout_error(defn)
|
|
124
|
+
FlowStepError.new("flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s")
|
|
138
125
|
end
|
|
139
126
|
|
|
140
127
|
def run_conditions(ctx, conditions, error_class:)
|
|
@@ -168,17 +155,6 @@ module Browserctl
|
|
|
168
155
|
"flow '#{@name}' step '#{defn.label}' failed: #{last_error.message}"
|
|
169
156
|
end
|
|
170
157
|
|
|
171
|
-
def execute_step_block(ctx, defn)
|
|
172
|
-
if defn.timeout
|
|
173
|
-
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
174
|
-
else
|
|
175
|
-
ctx.instance_exec(&defn.block)
|
|
176
|
-
end
|
|
177
|
-
rescue ::Timeout::Error
|
|
178
|
-
raise FlowStepError,
|
|
179
|
-
"flow '#{@name}' step '#{defn.label}' timed out after #{defn.timeout}s"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
158
|
def produce_state(ctx)
|
|
183
159
|
return nil unless @produces_state_block
|
|
184
160
|
|
|
@@ -5,8 +5,8 @@ require_relative "../../flow"
|
|
|
5
5
|
|
|
6
6
|
# Pauses for a human to solve a Cloudflare challenge (Turnstile, "Just a
|
|
7
7
|
# moment...", interactive checkbox), then verifies the challenge cleared
|
|
8
|
-
# before returning. Optionally saves the post-solve
|
|
9
|
-
# you can reload later with `state load
|
|
8
|
+
# before returning. Optionally saves the post-solve state under a name
|
|
9
|
+
# you can reload later with `state load`.
|
|
10
10
|
#
|
|
11
11
|
# Reuses Browserctl::Detectors.cloudflare? — the server-side detector
|
|
12
12
|
# already shipped in v0.8 — by adapting the client-facing PageProxy to
|
|
@@ -34,7 +34,7 @@ Browserctl.flow("cloudflare_solve") do
|
|
|
34
34
|
|
|
35
35
|
param :prompt,
|
|
36
36
|
default: "Cloudflare challenge detected. Solve it in the browser, then press Enter to continue."
|
|
37
|
-
param :state_name # optional — if set,
|
|
37
|
+
param :state_name # optional — if set, state_save is called after the challenge clears
|
|
38
38
|
|
|
39
39
|
precondition("page proxy is present") { !page.nil? }
|
|
40
40
|
precondition("cloudflare challenge is present") do
|
|
@@ -54,6 +54,6 @@ Browserctl.flow("cloudflare_solve") do
|
|
|
54
54
|
produces_state do
|
|
55
55
|
next nil unless state_name && client
|
|
56
56
|
|
|
57
|
-
client.
|
|
57
|
+
client.state_save(state_name)
|
|
58
58
|
end
|
|
59
59
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../error/codes"
|
|
8
|
+
|
|
9
|
+
module Browserctl
|
|
10
|
+
class Recording
|
|
11
|
+
# Owns recording-log file I/O: path resolution, header initialisation,
|
|
12
|
+
# JSONL append, raw read, deletion, and format-version validation.
|
|
13
|
+
#
|
|
14
|
+
# All paths are resolved against the parent `Recording::RECORDINGS_DIR`
|
|
15
|
+
# constant on each call so RSpec `stub_const` calls remain effective.
|
|
16
|
+
module LogWriter
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns the on-disk JSONL path for a named recording.
|
|
20
|
+
def log_path(name)
|
|
21
|
+
File.join(Browserctl::Recording::RECORDINGS_DIR, "#{name}.jsonl")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Truncates (or creates) the log for `name`, locks it down to user
|
|
25
|
+
# permissions, and writes the `_meta` header line. Returns the path.
|
|
26
|
+
def init_log(name)
|
|
27
|
+
FileUtils.mkdir_p(Browserctl::Recording::RECORDINGS_DIR, mode: 0o700)
|
|
28
|
+
path = log_path(name)
|
|
29
|
+
FileUtils.rm_f(path)
|
|
30
|
+
FileUtils.touch(path)
|
|
31
|
+
File.chmod(0o600, path)
|
|
32
|
+
File.open(path, "a") do |f|
|
|
33
|
+
f.puts JSON.generate(
|
|
34
|
+
cmd: "_meta",
|
|
35
|
+
format_version: Browserctl::Recording::RECORDING_FORMAT_VERSION,
|
|
36
|
+
log_format: Browserctl::Recording::LOG_FORMAT,
|
|
37
|
+
recording: name,
|
|
38
|
+
started_at: Time.now.utc.iso8601
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Appends a single JSONL entry to the log for `name`.
|
|
45
|
+
def append_entry(name, entry)
|
|
46
|
+
File.open(log_path(name), "a") do |f|
|
|
47
|
+
f.puts JSON.generate(entry)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the parsed lines for `name`, with symbolised keys.
|
|
52
|
+
def read_entries(name)
|
|
53
|
+
File.readlines(log_path(name)).map { |l| JSON.parse(l, symbolize_names: true) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Removes the log file for `name` if present.
|
|
57
|
+
def delete_log(name)
|
|
58
|
+
FileUtils.rm_f(log_path(name))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raises Browserctl::ProtocolMismatch when the recording log's
|
|
62
|
+
# `_meta` header is missing or declares a `format_version` that this
|
|
63
|
+
# build does not support. Mirrors `Browserctl::State::Bundle`.
|
|
64
|
+
def verify_format_version!(raw_lines, path: nil)
|
|
65
|
+
meta = raw_lines.first
|
|
66
|
+
version = meta && meta[:cmd] == "_meta" ? meta[:format_version] : nil
|
|
67
|
+
supported = Browserctl::Recording::SUPPORTED_FORMAT_VERSIONS
|
|
68
|
+
return if version && supported.include?(version)
|
|
69
|
+
|
|
70
|
+
where = path ? " at #{path}" : ""
|
|
71
|
+
msg = if version.nil?
|
|
72
|
+
"recording log#{where} is missing format_version " \
|
|
73
|
+
"(supported: #{supported.inspect})"
|
|
74
|
+
else
|
|
75
|
+
"recording log#{where} declares format_version=#{version.inspect}, " \
|
|
76
|
+
"this build supports #{supported.inspect}"
|
|
77
|
+
end
|
|
78
|
+
raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Recording
|
|
7
|
+
# Secret-aware redaction helpers used while a recording is being
|
|
8
|
+
# captured. Two responsibilities:
|
|
9
|
+
#
|
|
10
|
+
# 1. Inferring whether a `fill` selector is targeting a secret-shaped
|
|
11
|
+
# field, so the generated workflow can wire a `secret_ref:` param.
|
|
12
|
+
# 2. Stripping sensitive query-string values out of recorded URLs so
|
|
13
|
+
# they never reach disk.
|
|
14
|
+
module Redactor
|
|
15
|
+
# Query-string parameter names whose values are scrubbed when a
|
|
16
|
+
# navigate/page_open URL is recorded.
|
|
17
|
+
SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
|
|
18
|
+
|
|
19
|
+
# Selector tokens that signal a fill is targeting a secret-shaped
|
|
20
|
+
# field. The captured group is used as the inferred field name; that
|
|
21
|
+
# name later drives the generated `secret_ref:` placeholder.
|
|
22
|
+
SECRET_FIELD_PATTERN = Regexp.new(
|
|
23
|
+
'\b(password|passwd|api[_-]?key|token|secret|otp|pin|client[_-]?secret|access[_-]?token)\b',
|
|
24
|
+
Regexp::IGNORECASE
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Returns a normalised secret-field name (e.g. "api_key") inferred
|
|
30
|
+
# from the selector, or nil when the selector is missing or does not
|
|
31
|
+
# match the secret-field pattern.
|
|
32
|
+
def infer_secret_field(selector)
|
|
33
|
+
return nil unless selector
|
|
34
|
+
|
|
35
|
+
match = selector.match(SECRET_FIELD_PATTERN)
|
|
36
|
+
return nil unless match
|
|
37
|
+
|
|
38
|
+
match[1].downcase.gsub(/[^a-z0-9]/, "_")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns `url` with any sensitive query parameter values replaced
|
|
42
|
+
# by `[REDACTED]`. URLs that fail to parse are returned unchanged.
|
|
43
|
+
def redact_url(url)
|
|
44
|
+
uri = URI.parse(url)
|
|
45
|
+
return url if uri.query.nil?
|
|
46
|
+
|
|
47
|
+
uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
|
|
48
|
+
raw_key = ::Regexp.last_match(1)
|
|
49
|
+
key = URI.decode_www_form_component(raw_key)
|
|
50
|
+
key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
|
|
51
|
+
end
|
|
52
|
+
uri.to_s
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
url
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../error/codes"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
class Recording
|
|
9
|
+
# Singleton over the on-disk marker (`STATE_FILE`) that tracks which
|
|
10
|
+
# recording, if any, is currently active. Carved out of `Recording` so
|
|
11
|
+
# the facade stays focused on dispatch.
|
|
12
|
+
module State
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Returns the active recording name, or nil when no marker exists.
|
|
16
|
+
# Reads the constant lazily so RSpec `stub_const` calls on the parent
|
|
17
|
+
# `Recording::STATE_FILE` continue to take effect.
|
|
18
|
+
def active
|
|
19
|
+
path = Browserctl::Recording::STATE_FILE
|
|
20
|
+
File.exist?(path) ? File.read(path).strip : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes the marker for `name`. Caller is responsible for any
|
|
24
|
+
# additional setup (e.g. log initialisation).
|
|
25
|
+
def write(name)
|
|
26
|
+
path = Browserctl::Recording::STATE_FILE
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, name)
|
|
29
|
+
name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Removes the marker. Raises Browserctl::Error when no recording is
|
|
33
|
+
# active. The message is preserved verbatim from the pre-split
|
|
34
|
+
# facade so existing specs and CLI surfaces stay stable.
|
|
35
|
+
def clear!
|
|
36
|
+
name = active
|
|
37
|
+
raise Browserctl::Error, "no active recording — run: browserctl recording start <name>" unless name
|
|
38
|
+
|
|
39
|
+
File.unlink(Browserctl::Recording::STATE_FILE)
|
|
40
|
+
name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
class Recording
|
|
8
|
+
# Renders a recording log into the Ruby source of a workflow. Pure
|
|
9
|
+
# function over the parsed log entries — no I/O, no clock, no globals.
|
|
10
|
+
#
|
|
11
|
+
# Carved out of `Recording` so the facade stays focused on dispatch;
|
|
12
|
+
# the step-rendering rules (selector vs ref, inferred waits, URL and
|
|
13
|
+
# snapshot postconditions, secret param wiring) all live here.
|
|
14
|
+
module WorkflowRenderer
|
|
15
|
+
# Conservative thresholds for inferring an explicit wait between
|
|
16
|
+
# recorded steps. Gaps shorter than the threshold come from natural
|
|
17
|
+
# input cadence; gaps above it usually mean the page actually had
|
|
18
|
+
# work to do.
|
|
19
|
+
WAIT_THRESHOLD_SECONDS = 1.5
|
|
20
|
+
WAIT_PADDING_SECONDS = 5
|
|
21
|
+
WAIT_FLOOR_SECONDS = 5
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Returns the Ruby source string for the workflow named `name`,
|
|
26
|
+
# given the parsed (non-meta) command entries.
|
|
27
|
+
def render(name, commands)
|
|
28
|
+
steps = annotated_steps(commands).join("\n\n")
|
|
29
|
+
secrets = commands.map { |c| c[:secret_field] }.compact.uniq
|
|
30
|
+
header = secret_header(secrets)
|
|
31
|
+
<<~RUBY
|
|
32
|
+
# frozen_string_literal: true
|
|
33
|
+
# format_version: #{Browserctl::WORKFLOW_FORMAT_VERSION}
|
|
34
|
+
#{header}
|
|
35
|
+
Browserctl.workflow #{name.inspect} do
|
|
36
|
+
desc "Recorded on #{Date.today}"
|
|
37
|
+
#{secrets.map { |f| " param :secret_#{f}, secret: true" }.join("\n")}
|
|
38
|
+
#{steps.gsub(/^/, ' ')}
|
|
39
|
+
end
|
|
40
|
+
RUBY
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Walks the recorded events and emits the rendered step strings,
|
|
44
|
+
# interleaving inferred waits before selector-driven actions whose
|
|
45
|
+
# preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL
|
|
46
|
+
# postconditions after click/fill steps that triggered navigation.
|
|
47
|
+
def annotated_steps(commands)
|
|
48
|
+
last_url = {}
|
|
49
|
+
commands.each_with_index.flat_map do |cmd, i|
|
|
50
|
+
rendered = []
|
|
51
|
+
if i.positive? && (wait = inferred_wait_step(commands[i - 1], cmd))
|
|
52
|
+
rendered << wait
|
|
53
|
+
end
|
|
54
|
+
rendered << build_step(cmd)
|
|
55
|
+
if (post = url_postcondition_step(cmd, last_url))
|
|
56
|
+
rendered << post
|
|
57
|
+
end
|
|
58
|
+
if (snap = snapshot_postcondition_step(cmd))
|
|
59
|
+
rendered << snap
|
|
60
|
+
end
|
|
61
|
+
update_last_url!(cmd, last_url)
|
|
62
|
+
rendered
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Emits a postcondition assertion when a click/fill resulted in a URL
|
|
67
|
+
# change. Compares the canonical (scheme+host+path) form so query
|
|
68
|
+
# strings and fragments don't make every replay flaky.
|
|
69
|
+
def url_postcondition_step(cmd, last_url)
|
|
70
|
+
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
71
|
+
return nil unless cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
72
|
+
|
|
73
|
+
page = cmd[:name]
|
|
74
|
+
observed = cmd[:postcondition_hint][:url]
|
|
75
|
+
prior = last_url[page]
|
|
76
|
+
return nil if canonical_url(observed) == canonical_url(prior)
|
|
77
|
+
|
|
78
|
+
prefix = canonical_url(observed)
|
|
79
|
+
return nil unless prefix
|
|
80
|
+
|
|
81
|
+
<<~RUBY.chomp
|
|
82
|
+
step "assert url after #{cmd[:cmd]} on #{page}" do
|
|
83
|
+
current = page(:#{page}).url
|
|
84
|
+
assert current.start_with?(#{prefix.inspect}), "expected URL to start with #{prefix}, got \#{current}"
|
|
85
|
+
end
|
|
86
|
+
RUBY
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Emits an assert_snapshot_stable step when the recording captured a
|
|
90
|
+
# post-step DOM digest. Under workflow run --check the helper records
|
|
91
|
+
# drift on mismatch instead of raising, so a wiggly page surfaces in
|
|
92
|
+
# the report rather than failing the run outright.
|
|
93
|
+
def snapshot_postcondition_step(cmd)
|
|
94
|
+
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
95
|
+
return nil unless cmd[:post_snapshot_digest]
|
|
96
|
+
|
|
97
|
+
page = cmd[:name]
|
|
98
|
+
digest = cmd[:post_snapshot_digest]
|
|
99
|
+
<<~RUBY.chomp
|
|
100
|
+
step "assert post-snapshot stable on #{page}" do
|
|
101
|
+
assert_snapshot_stable(:#{page}, expected_digest: #{digest.inspect})
|
|
102
|
+
end
|
|
103
|
+
RUBY
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def update_last_url!(cmd, last_url)
|
|
107
|
+
case cmd[:cmd]
|
|
108
|
+
when "navigate", "page_open"
|
|
109
|
+
last_url[cmd[:name]] = cmd[:url] if cmd[:url]
|
|
110
|
+
when "click", "fill"
|
|
111
|
+
observed = cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
112
|
+
last_url[cmd[:name]] = observed if observed
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def canonical_url(url)
|
|
117
|
+
return nil if url.nil? || url.empty?
|
|
118
|
+
|
|
119
|
+
uri = URI.parse(url)
|
|
120
|
+
path = uri.path.to_s
|
|
121
|
+
path = "/" if path.empty?
|
|
122
|
+
"#{uri.scheme}://#{uri.host}#{path}"
|
|
123
|
+
rescue URI::InvalidURIError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inferred_wait_step(prev, current)
|
|
128
|
+
return nil unless %w[fill click].include?(current[:cmd])
|
|
129
|
+
return nil unless current[:selector]
|
|
130
|
+
|
|
131
|
+
delta = elapsed(prev, current)
|
|
132
|
+
return nil unless delta && delta >= WAIT_THRESHOLD_SECONDS
|
|
133
|
+
|
|
134
|
+
timeout = [WAIT_FLOOR_SECONDS, delta.ceil + WAIT_PADDING_SECONDS].max
|
|
135
|
+
page = current[:name]
|
|
136
|
+
sel = current[:selector]
|
|
137
|
+
<<~RUBY.chomp
|
|
138
|
+
# inferred wait: prior step took ~#{format('%.1f', delta)}s
|
|
139
|
+
step "wait for #{sel} on #{page}" do
|
|
140
|
+
page(:#{page}).wait(#{sel.inspect}, timeout: #{timeout})
|
|
141
|
+
end
|
|
142
|
+
RUBY
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def elapsed(prev, current)
|
|
146
|
+
return nil unless prev && current && prev[:ts] && current[:ts]
|
|
147
|
+
|
|
148
|
+
current[:ts] - prev[:ts]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def secret_header(secrets)
|
|
152
|
+
return "" if secrets.empty?
|
|
153
|
+
|
|
154
|
+
lines = ["# TODO: review the following secret-shaped fields detected during recording.",
|
|
155
|
+
"# Configure a secret_ref: source for each before running:"]
|
|
156
|
+
secrets.each { |f| lines << "# - secret_#{f}" }
|
|
157
|
+
"\n#{lines.join("\n")}\n"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_step(cmd)
|
|
161
|
+
label, body = step_parts(cmd)
|
|
162
|
+
|
|
163
|
+
if body.nil?
|
|
164
|
+
page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
165
|
+
action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
|
|
166
|
+
return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
|
|
167
|
+
"replace with a stable CSS selector\n" \
|
|
168
|
+
"# step #{label.inspect} do\n" \
|
|
169
|
+
"# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
|
|
170
|
+
"# end"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
prefix = []
|
|
174
|
+
prefix << "# NOTE: sensitive query params were redacted during recording" \
|
|
175
|
+
if cmd[:url].to_s.include?("[REDACTED]")
|
|
176
|
+
prefix << "# fingerprint fallback: #{cmd[:fingerprint].to_json}" if cmd[:fingerprint]
|
|
177
|
+
|
|
178
|
+
head = prefix.empty? ? "" : "#{prefix.join("\n")}\n"
|
|
179
|
+
"#{head}step #{label.inspect} do\n #{body}\nend"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def step_parts(cmd)
|
|
183
|
+
return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
|
|
184
|
+
return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
|
|
185
|
+
|
|
186
|
+
page = cmd[:name]
|
|
187
|
+
case cmd[:cmd]
|
|
188
|
+
when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
189
|
+
when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
190
|
+
when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
191
|
+
when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
192
|
+
else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ref_interaction_parts(cmd)
|
|
197
|
+
["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def selector_parts(cmd)
|
|
201
|
+
page = cmd[:name]
|
|
202
|
+
case cmd[:cmd]
|
|
203
|
+
when "fill"
|
|
204
|
+
value_arg = cmd[:secret_field] ? "params[:secret_#{cmd[:secret_field]}]" : "params[:fill_value]"
|
|
205
|
+
["fill #{cmd[:selector]} on #{page}",
|
|
206
|
+
"page(:#{page}).fill(#{cmd[:selector].inspect}, #{value_arg})"]
|
|
207
|
+
when "click"
|
|
208
|
+
["click #{cmd[:selector]} on #{page}",
|
|
209
|
+
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|