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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +3 -3
  4. data/bin/browserctl +39 -32
  5. data/lib/browserctl/callable_definition.rb +114 -0
  6. data/lib/browserctl/client.rb +0 -27
  7. data/lib/browserctl/commands/cli_output.rb +17 -3
  8. data/lib/browserctl/commands/daemon.rb +10 -6
  9. data/lib/browserctl/commands/flow.rb +7 -5
  10. data/lib/browserctl/commands/init.rb +20 -7
  11. data/lib/browserctl/commands/migrate.rb +56 -8
  12. data/lib/browserctl/commands/output_format.rb +144 -0
  13. data/lib/browserctl/commands/page.rb +9 -5
  14. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  15. data/lib/browserctl/commands/resume.rb +1 -1
  16. data/lib/browserctl/commands/screenshot.rb +2 -2
  17. data/lib/browserctl/commands/snapshot.rb +8 -3
  18. data/lib/browserctl/commands/state.rb +3 -2
  19. data/lib/browserctl/commands/trace.rb +40 -11
  20. data/lib/browserctl/commands/workflow.rb +9 -7
  21. data/lib/browserctl/contextual_persistence.rb +58 -0
  22. data/lib/browserctl/driver/cdp.rb +2 -3
  23. data/lib/browserctl/encryption_service.rb +84 -0
  24. data/lib/browserctl/flow.rb +35 -59
  25. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  26. data/lib/browserctl/recording/log_writer.rb +82 -0
  27. data/lib/browserctl/recording/redactor.rb +58 -0
  28. data/lib/browserctl/recording/state.rb +44 -0
  29. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  30. data/lib/browserctl/recording.rb +33 -294
  31. data/lib/browserctl/server/command_dispatcher.rb +25 -16
  32. data/lib/browserctl/server/handlers/state.rb +7 -5
  33. data/lib/browserctl/server.rb +2 -1
  34. data/lib/browserctl/state/bundle.rb +20 -47
  35. data/lib/browserctl/state.rb +46 -9
  36. data/lib/browserctl/version.rb +1 -1
  37. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  38. data/lib/browserctl/workflow.rb +61 -237
  39. metadata +11 -8
  40. data/examples/session_reuse.rb +0 -75
  41. data/lib/browserctl/commands/session.rb +0 -243
  42. data/lib/browserctl/driver/base.rb +0 -13
  43. data/lib/browserctl/driver.rb +0 -5
  44. data/lib/browserctl/server/handlers/session.rb +0 -94
  45. 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
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
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 :name,
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
- @name = name.to_s
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 resolve_params(provided)
125
- @param_defs.each_with_object({}) do |(name, defn), out|
126
- val = if defn.secret_ref
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
- out[name] = val
137
- end
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 session under a name
9
- # you can reload later with `state load` or `session_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, session_save is called after the challenge clears
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.session_save(state_name)
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