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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "error/codes"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
# Migration registry for browserctl persisted formats. Operators run
|
|
9
|
+
# `browserctl migrate <path>` to upgrade an artifact written by an older
|
|
10
|
+
# browserctl to the current build's format version.
|
|
11
|
+
#
|
|
12
|
+
# Distinct from `verify_format_version!` (in bundle/recording/workflow):
|
|
13
|
+
# those gates stay strict — a reader that encounters an unknown version
|
|
14
|
+
# raises {Browserctl::ProtocolMismatch}. This module is the only blessed
|
|
15
|
+
# path to mutate an old artifact in place.
|
|
16
|
+
#
|
|
17
|
+
# The registry ships **empty** in v0.12. The first real migration arrives
|
|
18
|
+
# only when a format actually changes post-1.0. See
|
|
19
|
+
# `docs/reference/format-versions.md` ("Migration registry").
|
|
20
|
+
module Migrations
|
|
21
|
+
# Single registered upgrader. `upgrade` is a Proc invoked with the
|
|
22
|
+
# absolute path of the file being migrated; it is responsible for
|
|
23
|
+
# rewriting that file in place, advancing it from `from_version` to
|
|
24
|
+
# `to_version`.
|
|
25
|
+
Migration = Struct.new(:format, :from_version, :to_version, :upgrade, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# Result of {.run}. `applied` is the ordered list of {Migration} steps
|
|
28
|
+
# that ran. When the artifact was already at target, `applied` is empty.
|
|
29
|
+
Result = Struct.new(:format, :from, :to, :applied, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
FORMAT_EXTENSIONS = {
|
|
32
|
+
".bctl" => :bundle,
|
|
33
|
+
".jsonl" => :recording,
|
|
34
|
+
".rb" => :workflow
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
@registry = []
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Registers an upgrader for one hop in `format`'s version chain. The
|
|
42
|
+
# block receives the file path as a keyword argument and must rewrite
|
|
43
|
+
# the file in place to the new version.
|
|
44
|
+
def register(format:, from_version:, to_version:, &upgrade)
|
|
45
|
+
raise ArgumentError, "upgrade block required" unless upgrade
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@registry << Migration.new(format: format, from_version: from_version,
|
|
49
|
+
to_version: to_version, upgrade: upgrade)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# All registered migrations, in registration order.
|
|
54
|
+
def all
|
|
55
|
+
@mutex.synchronize { @registry.dup }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Test-only hook — clears the registry. Not part of the public API.
|
|
59
|
+
def reset!
|
|
60
|
+
@mutex.synchronize { @registry.clear }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Breadth-first search through registered migrations to chain a path
|
|
64
|
+
# for `format` from `from` to `to`. Returns the ordered list of
|
|
65
|
+
# {Migration} hops, or `nil` if no path is reachable. When `from == to`
|
|
66
|
+
# returns an empty array (already at target — no work to do).
|
|
67
|
+
def find_path(format:, from:, to:)
|
|
68
|
+
return [] if from == to
|
|
69
|
+
|
|
70
|
+
all_for_format = all.select { |m| m.format == format }
|
|
71
|
+
queue = [[from, []]]
|
|
72
|
+
seen = { from => true }
|
|
73
|
+
|
|
74
|
+
until queue.empty?
|
|
75
|
+
current, path = queue.shift
|
|
76
|
+
all_for_format.each do |m|
|
|
77
|
+
next unless m.from_version == current
|
|
78
|
+
next if seen[m.to_version]
|
|
79
|
+
|
|
80
|
+
new_path = path + [m]
|
|
81
|
+
return new_path if m.to_version == to
|
|
82
|
+
|
|
83
|
+
seen[m.to_version] = true
|
|
84
|
+
queue << [m.to_version, new_path]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Inspects an artifact path and returns a format symbol — `:bundle`,
|
|
91
|
+
# `:recording`, or `:workflow` — or `nil` when the format cannot be
|
|
92
|
+
# identified. Detection is extension-driven: keep new formats listed
|
|
93
|
+
# in {FORMAT_EXTENSIONS} so this stays a one-line lookup.
|
|
94
|
+
def detect_format(path)
|
|
95
|
+
FORMAT_EXTENSIONS[File.extname(path.to_s).downcase]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reads `path` and returns the integer format_version it declares, or
|
|
99
|
+
# `nil` when no version header is present. Format-specific because
|
|
100
|
+
# each format stores the header differently — the bundle in its
|
|
101
|
+
# binary manifest, the recording in a `_meta` JSONL line, the
|
|
102
|
+
# workflow in a Ruby comment.
|
|
103
|
+
def detect_version(path, format)
|
|
104
|
+
case format
|
|
105
|
+
when :bundle then peek_bundle_version(path)
|
|
106
|
+
when :recording then peek_recording_version(path)
|
|
107
|
+
when :workflow then peek_workflow_version(path)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# End-to-end migration. Detects format and current version, finds a
|
|
112
|
+
# chain of registered upgraders to `target_version` (or the latest
|
|
113
|
+
# `to_version` seen for this format if `target_version` is nil), and
|
|
114
|
+
# invokes each in order. Each upgrader rewrites the file in place.
|
|
115
|
+
#
|
|
116
|
+
# Returns a {Result}. When no migrations are needed (or the registry
|
|
117
|
+
# has no entries for this format), `applied` is empty.
|
|
118
|
+
#
|
|
119
|
+
# Raises {Browserctl::ProtocolMismatch} when format detection fails,
|
|
120
|
+
# the version cannot be read, or no chain reaches the target.
|
|
121
|
+
def run(path, target_version: nil)
|
|
122
|
+
format = detect_format(path) or raise_protocol("could not detect format for #{path}")
|
|
123
|
+
current = detect_version(path, format) or raise_protocol("could not read format_version from #{path}")
|
|
124
|
+
target = target_version || latest_known_target(format, current)
|
|
125
|
+
|
|
126
|
+
if current == target
|
|
127
|
+
# If we'd be a no-op but the artifact's declared version is one this
|
|
128
|
+
# build's reader does not support, surface that as PROTOCOL_MISMATCH —
|
|
129
|
+
# there is no migration registered to bring it into range.
|
|
130
|
+
unless format_version_supported?(format, current)
|
|
131
|
+
raise_protocol("#{format} at #{path} declares unsupported format_version=#{current}; " \
|
|
132
|
+
"no migration registered")
|
|
133
|
+
end
|
|
134
|
+
return Result.new(format: format, from: current, to: current, applied: [])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
chain = find_path(format: format, from: current, to: target)
|
|
138
|
+
raise_protocol("no migration path from #{format} v#{current} to v#{target}") if chain.nil?
|
|
139
|
+
|
|
140
|
+
chain.each { |m| m.upgrade.call(path: path, from_version: m.from_version, to_version: m.to_version) }
|
|
141
|
+
Result.new(format: format, from: current, to: target, applied: chain)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Reflects whether each format's reader currently accepts a given
|
|
147
|
+
# version. Mirrors the SUPPORTED_FORMAT_VERSIONS constant on the
|
|
148
|
+
# corresponding class — kept here so adding a new format is one
|
|
149
|
+
# branch, not a new public API.
|
|
150
|
+
def format_version_supported?(format, version)
|
|
151
|
+
case format
|
|
152
|
+
when :bundle
|
|
153
|
+
require_relative "state/bundle"
|
|
154
|
+
Browserctl::State::Bundle::SUPPORTED_FORMAT_VERSIONS.include?(version)
|
|
155
|
+
when :recording
|
|
156
|
+
require_relative "recording"
|
|
157
|
+
Browserctl::Recording::SUPPORTED_FORMAT_VERSIONS.include?(version)
|
|
158
|
+
when :workflow
|
|
159
|
+
require_relative "workflow"
|
|
160
|
+
Browserctl::SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
|
|
161
|
+
else
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def latest_known_target(format, current)
|
|
167
|
+
targets = all.select { |m| m.format == format }.map(&:to_version)
|
|
168
|
+
targets.empty? ? current : targets.max
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def peek_bundle_version(path)
|
|
172
|
+
require_relative "state/bundle"
|
|
173
|
+
manifest = Browserctl::State::Bundle.peek_manifest(File.binread(path))
|
|
174
|
+
manifest[:format_version] || manifest["format_version"]
|
|
175
|
+
rescue Browserctl::ProtocolMismatch
|
|
176
|
+
# `peek_manifest` raises on unknown versions, but we want to *return*
|
|
177
|
+
# the version so a migration can target it. Re-parse the manifest
|
|
178
|
+
# bytes directly when the strict gate refuses.
|
|
179
|
+
peek_bundle_version_loosely(path)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def peek_bundle_version_loosely(path)
|
|
183
|
+
blob = File.binread(path)
|
|
184
|
+
# Manifest length is at byte offset 8 (5 magic + 3 header). Parse
|
|
185
|
+
# the JSON without invoking Bundle's strict version check.
|
|
186
|
+
manifest_len = blob.byteslice(8, 4).unpack1("N")
|
|
187
|
+
manifest_bytes = blob.byteslice(12, manifest_len)
|
|
188
|
+
manifest = JSON.parse(manifest_bytes)
|
|
189
|
+
manifest["format_version"]
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def peek_recording_version(path)
|
|
195
|
+
first_line = File.foreach(path).first
|
|
196
|
+
return nil unless first_line
|
|
197
|
+
|
|
198
|
+
meta = JSON.parse(first_line, symbolize_names: true)
|
|
199
|
+
return nil unless meta[:cmd] == "_meta"
|
|
200
|
+
|
|
201
|
+
meta[:format_version]
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def peek_workflow_version(path)
|
|
207
|
+
require_relative "workflow"
|
|
208
|
+
Browserctl.parse_workflow_format_version(File.read(path))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def raise_protocol(msg)
|
|
212
|
+
raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
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
|