Module: Browserctl::Recording::WorkflowRenderer
- Defined in:
- lib/browserctl/recording/workflow_renderer.rb
Overview
Renders a recording log into the Ruby source of a workflow. Pure function over the parsed log entries — no I/O, no clock, no globals.
Carved out of ‘Recording` so the facade stays focused on dispatch; the step-rendering rules (selector vs ref, inferred waits, URL and snapshot postconditions, secret param wiring) all live here.
Constant Summary collapse
- WAIT_THRESHOLD_SECONDS =
Conservative thresholds for inferring an explicit wait between recorded steps. Gaps shorter than the threshold come from natural input cadence; gaps above it usually mean the page actually had work to do.
1.5- WAIT_PADDING_SECONDS =
5- WAIT_FLOOR_SECONDS =
5
Class Method Summary collapse
-
.annotated_steps(commands) ⇒ Object
Walks the recorded events and emits the rendered step strings, interleaving inferred waits before selector-driven actions whose preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL postconditions after click/fill steps that triggered navigation.
- .build_step(cmd) ⇒ Object
- .canonical_url(url) ⇒ Object
- .elapsed(prev, current) ⇒ Object
- .inferred_wait_step(prev, current) ⇒ Object
- .ref_interaction_parts(cmd) ⇒ Object
-
.render(name, commands) ⇒ Object
Returns the Ruby source string for the workflow named ‘name`, given the parsed (non-meta) command entries.
- .secret_header(secrets) ⇒ Object
- .selector_parts(cmd) ⇒ Object
-
.snapshot_postcondition_step(cmd) ⇒ Object
Emits an assert_snapshot_stable step when the recording captured a post-step DOM digest.
- .step_parts(cmd) ⇒ Object
- .update_last_url!(cmd, last_url) ⇒ Object
-
.url_postcondition_step(cmd, last_url) ⇒ Object
Emits a postcondition assertion when a click/fill resulted in a URL change.
Class Method Details
.annotated_steps(commands) ⇒ Object
Walks the recorded events and emits the rendered step strings, interleaving inferred waits before selector-driven actions whose preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL postconditions after click/fill steps that triggered navigation.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 47 def annotated_steps(commands) last_url = {} commands.each_with_index.flat_map do |cmd, i| rendered = [] if i.positive? && (wait = inferred_wait_step(commands[i - 1], cmd)) rendered << wait end rendered << build_step(cmd) if (post = url_postcondition_step(cmd, last_url)) rendered << post end if (snap = snapshot_postcondition_step(cmd)) rendered << snap end update_last_url!(cmd, last_url) rendered end end |
.build_step(cmd) ⇒ Object
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 160 def build_step(cmd) label, body = step_parts(cmd) if body.nil? page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_") action = cmd[:action].to_s.gsub(/[^a-z_]/, "") return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \ "replace with a stable CSS selector\n" \ "# step #{label.inspect} do\n" \ "# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \ "# end" end prefix = [] prefix << "# NOTE: sensitive query params were redacted during recording" \ if cmd[:url].to_s.include?("[REDACTED]") prefix << "# fingerprint fallback: #{cmd[:fingerprint].to_json}" if cmd[:fingerprint] head = prefix.empty? ? "" : "#{prefix.join("\n")}\n" "#{head}step #{label.inspect} do\n #{body}\nend" end |
.canonical_url(url) ⇒ Object
116 117 118 119 120 121 122 123 124 125 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 116 def canonical_url(url) return nil if url.nil? || url.empty? uri = URI.parse(url) path = uri.path.to_s path = "/" if path.empty? "#{uri.scheme}://#{uri.host}#{path}" rescue URI::InvalidURIError nil end |
.elapsed(prev, current) ⇒ Object
145 146 147 148 149 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 145 def elapsed(prev, current) return nil unless prev && current && prev[:ts] && current[:ts] current[:ts] - prev[:ts] end |
.inferred_wait_step(prev, current) ⇒ Object
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 127 def inferred_wait_step(prev, current) return nil unless %w[fill click].include?(current[:cmd]) return nil unless current[:selector] delta = elapsed(prev, current) return nil unless delta && delta >= WAIT_THRESHOLD_SECONDS timeout = [WAIT_FLOOR_SECONDS, delta.ceil + WAIT_PADDING_SECONDS].max page = current[:name] sel = current[:selector] <<~RUBY.chomp # inferred wait: prior step took ~#{format('%.1f', delta)}s step "wait for #{sel} on #{page}" do page(:#{page}).wait(#{sel.inspect}, timeout: #{timeout}) end RUBY end |
.ref_interaction_parts(cmd) ⇒ Object
196 197 198 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 196 def ref_interaction_parts(cmd) ["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil] end |
.render(name, commands) ⇒ Object
Returns the Ruby source string for the workflow named ‘name`, given the parsed (non-meta) command entries.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 27 def render(name, commands) steps = annotated_steps(commands).join("\n\n") secrets = commands.map { |c| c[:secret_field] }.compact.uniq header = secret_header(secrets) <<~RUBY # frozen_string_literal: true # format_version: #{Browserctl::WORKFLOW_FORMAT_VERSION} #{header} Browserctl.workflow #{name.inspect} do desc "Recorded on #{Date.today}" #{secrets.map { |f| " param :secret_#{f}, secret: true" }.join("\n")} #{steps.gsub(/^/, ' ')} end RUBY end |
.secret_header(secrets) ⇒ Object
151 152 153 154 155 156 157 158 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 151 def secret_header(secrets) return "" if secrets.empty? lines = ["# TODO: review the following secret-shaped fields detected during recording.", "# Configure a secret_ref: source for each before running:"] secrets.each { |f| lines << "# - secret_#{f}" } "\n#{lines.join("\n")}\n" end |
.selector_parts(cmd) ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 200 def selector_parts(cmd) page = cmd[:name] case cmd[:cmd] when "fill" value_arg = cmd[:secret_field] ? "params[:secret_#{cmd[:secret_field]}]" : "params[:fill_value]" ["fill #{cmd[:selector]} on #{page}", "page(:#{page}).fill(#{cmd[:selector].inspect}, #{value_arg})"] when "click" ["click #{cmd[:selector]} on #{page}", "page(:#{page}).click(#{cmd[:selector].inspect})"] end end |
.snapshot_postcondition_step(cmd) ⇒ Object
Emits an assert_snapshot_stable step when the recording captured a post-step DOM digest. Under workflow run –check the helper records drift on mismatch instead of raising, so a wiggly page surfaces in the report rather than failing the run outright.
93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 93 def snapshot_postcondition_step(cmd) return nil unless %w[click fill].include?(cmd[:cmd]) return nil unless cmd[:post_snapshot_digest] page = cmd[:name] digest = cmd[:post_snapshot_digest] <<~RUBY.chomp step "assert post-snapshot stable on #{page}" do assert_snapshot_stable(:#{page}, expected_digest: #{digest.inspect}) end RUBY end |
.step_parts(cmd) ⇒ Object
182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 182 def step_parts(cmd) return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction" return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd]) page = cmd[:name] case cmd[:cmd] when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"] when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"] when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"] when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"] else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"] end end |
.update_last_url!(cmd, last_url) ⇒ Object
106 107 108 109 110 111 112 113 114 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 106 def update_last_url!(cmd, last_url) case cmd[:cmd] when "navigate", "page_open" last_url[cmd[:name]] = cmd[:url] if cmd[:url] when "click", "fill" observed = cmd[:postcondition_hint] && cmd[:postcondition_hint][:url] last_url[cmd[:name]] = observed if observed end end |
.url_postcondition_step(cmd, last_url) ⇒ Object
Emits a postcondition assertion when a click/fill resulted in a URL change. Compares the canonical (scheme+host+path) form so query strings and fragments don’t make every replay flaky.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/browserctl/recording/workflow_renderer.rb', line 69 def url_postcondition_step(cmd, last_url) return nil unless %w[click fill].include?(cmd[:cmd]) return nil unless cmd[:postcondition_hint] && cmd[:postcondition_hint][:url] page = cmd[:name] observed = cmd[:postcondition_hint][:url] prior = last_url[page] return nil if canonical_url(observed) == canonical_url(prior) prefix = canonical_url(observed) return nil unless prefix <<~RUBY.chomp step "assert url after #{cmd[:cmd]} on #{page}" do current = page(:#{page}).url assert current.start_with?(#{prefix.inspect}), "expected URL to start with #{prefix}, got \#{current}" end RUBY end |