browserctl 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -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