browserctl 0.2.0 → 0.3.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.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module PauseResume
8
+ extend CliOutput
9
+
10
+ def self.pause(client, args)
11
+ name = args.shift or abort "usage: browserctl pause <page>"
12
+ res = client.pause(name)
13
+ if res[:error]
14
+ warn "Error: #{res[:error]}"
15
+ exit 1
16
+ end
17
+ puts "Page '#{name}' paused. Browser is live — interact freely."
18
+ puts "When done: browserctl resume #{name}"
19
+ end
20
+
21
+ def self.resume(client, args)
22
+ name = args.shift or abort "usage: browserctl resume <page>"
23
+ res = client.resume(name)
24
+ if res[:error]
25
+ warn "Error: #{res[:error]}"
26
+ exit 1
27
+ end
28
+ puts "Page '#{name}' resumed."
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "optimist"
4
5
  require "browserctl/recording"
5
6
 
6
7
  module Browserctl
7
8
  module Commands
8
9
  class Record
9
- USAGE = "usage: browserctl record start <name> | stop [--out path] | status"
10
+ USAGE = "Usage: browserctl record start <name> | stop [--out PATH] | status"
10
11
 
11
12
  def self.run(args)
12
13
  subcmd = args.shift
@@ -14,7 +15,8 @@ module Browserctl
14
15
  when "start" then run_start(args)
15
16
  when "stop" then run_stop(args)
16
17
  when "status" then run_status
17
- else abort USAGE
18
+ else
19
+ abort "#{USAGE}\nRun 'browserctl record <subcommand> --help' for details."
18
20
  end
19
21
  end
20
22
 
@@ -22,6 +24,7 @@ module Browserctl
22
24
  private
23
25
 
24
26
  def run_start(args)
27
+ Optimist.options(args) { banner "Usage: browserctl record start <name>" }
25
28
  name = args.shift or abort "usage: browserctl record start <name>"
26
29
  Recording.start(name)
27
30
  puts "Recording started: #{name}"
@@ -29,23 +32,15 @@ module Browserctl
29
32
  end
30
33
 
31
34
  def run_stop(args)
32
- idx = args.index("--out")
33
- out = if idx
34
- args.delete_at(idx)
35
- args.delete_at(idx)
36
- end
37
- name = Recording.stop
38
- if out
39
- FileUtils.mkdir_p(File.dirname(out))
40
- Recording.generate_workflow(name, output_path: out)
41
- puts "Workflow saved: #{out}"
42
- else
43
- dest_dir = ".browserctl/workflows"
44
- dest_file = File.join(dest_dir, "#{name}.rb")
45
- FileUtils.mkdir_p(dest_dir)
46
- Recording.generate_workflow(name, output_path: dest_file)
47
- puts "Workflow saved: #{dest_file}"
35
+ opts = Optimist.options(args) do
36
+ banner "Usage: browserctl record stop [--out PATH]"
37
+ opt :out, "Output path for workflow file", type: :string, short: "-o"
48
38
  end
39
+ name = Recording.stop
40
+ out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
41
+ FileUtils.mkdir_p(File.dirname(out))
42
+ Recording.generate_workflow(name, output_path: out)
43
+ puts "Workflow saved: #{out}"
49
44
  puts "Run with: browserctl run #{name}"
50
45
  end
51
46
 
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "flag_extractor"
3
+ require "optimist"
4
+ require_relative "cli_output"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
7
8
  class Screenshot
9
+ extend CliOutput
10
+
8
11
  def self.run(client, args)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl shot <page> [--out PATH] [--full]"
14
+ opt :out, "Output file path", type: :string, short: "-o"
15
+ opt :full, "Capture full page", default: false, short: "-f"
16
+ end
9
17
  name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
10
- path = FlagExtractor.extract_opt(args, "--out")
11
- full = args.delete("--full") ? true : false
12
- puts client.screenshot(name, path: path, full: full).to_json
18
+ print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
13
19
  end
14
20
  end
15
21
  end
@@ -1,25 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "flag_extractor"
4
+ require "optimist"
5
5
 
6
6
  module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
+ VALID_FORMATS = %w[ai html].freeze
10
+
9
11
  def self.run(client, args)
10
- name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
11
- format = FlagExtractor.extract_opt(args, "--format") || "ai"
12
- diff = FlagExtractor.extract_flag?(args, "--diff")
13
- res = client.snapshot(name, format: format, diff: diff)
14
- output_snapshot(res, format)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
14
+ opt :format, "Output format: ai or html", default: "ai", short: "-f"
15
+ opt :diff, "Return only changed elements", default: false, short: "-d"
16
+ end
17
+ name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
18
+ unless VALID_FORMATS.include?(opts[:format])
19
+ warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
+ exit 1
21
+ end
22
+ res = client.snapshot(name, format: opts[:format], diff: opts[:diff])
23
+ output_snapshot(res, opts[:format])
15
24
  end
16
25
 
17
26
  class << self
18
27
  private
19
28
 
20
29
  def output_snapshot(res, format)
21
- return abort res[:error] if res[:error]
22
-
30
+ if res[:error]
31
+ warn "Error: #{res[:error]}"
32
+ exit 1
33
+ end
23
34
  puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
24
35
  end
25
36
  end
@@ -1,16 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "flag_extractor"
3
+ require "optimist"
4
+ require_relative "cli_output"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
7
8
  class Watch
9
+ extend CliOutput
10
+
8
11
  def self.run(client, args)
12
+ opts = Optimist.options(args) do
13
+ banner "Usage: browserctl watch <page> <selector> [--timeout N]"
14
+ opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
15
+ end
9
16
  name = args.shift
10
17
  selector = args.shift
11
- timeout = (FlagExtractor.extract_opt(args, "--timeout") || 30).to_f
12
18
  abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
13
- puts client.watch(name, selector, timeout: timeout).to_json
19
+ unless opts[:timeout].positive?
20
+ warn "Error: --timeout must be a positive number"
21
+ exit 1
22
+ end
23
+ print_result(client.watch(name, selector, timeout: opts[:timeout]))
14
24
  end
15
25
  end
16
26
  end
@@ -39,6 +39,8 @@ module Browserctl
39
39
  # ref-based interactions have no replayable selector — skip them
40
40
  return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
41
41
 
42
+ attrs = attrs.except(:value) if cmd.to_s == "fill"
43
+
42
44
  File.open(log_path(name), "a") do |f|
43
45
  f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
44
46
  end
@@ -90,7 +92,7 @@ module Browserctl
90
92
  ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
91
93
  when "fill"
92
94
  ["fill #{cmd[:selector]} on #{page}",
93
- "page(:#{page}).fill(#{cmd[:selector].inspect}, #{cmd[:value].inspect})"]
95
+ "page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
94
96
  when "click"
95
97
  ["click #{cmd[:selector]} on #{page}",
96
98
  "page(:#{page}).click(#{cmd[:selector].inspect})"]
@@ -10,6 +10,11 @@ module Browserctl
10
10
  File.expand_path("~/.browserctl/workflows")
11
11
  ].freeze
12
12
 
13
+ # Runs a named workflow with the given parameters.
14
+ # @param name [String] workflow name (must match /\A[a-zA-Z0-9_-]+\z/)
15
+ # @param params [Hash] keyword arguments passed to the workflow
16
+ # @return [Boolean] true if all steps succeeded
17
+ # @raise [WorkflowError] if the name is invalid or a step fails
13
18
  def run_workflow(name, **params)
14
19
  defn = fetch_workflow(name)
15
20
  results = defn.call(params, Client.new)
@@ -17,19 +22,33 @@ module Browserctl
17
22
  results.all?(&:ok)
18
23
  end
19
24
 
25
+ # Lists all registered workflows from the standard search paths.
26
+ # @return [Array<Hash>] array of `{ name:, desc: }` hashes
20
27
  def list_workflows
21
28
  load_all_workflows
22
29
  REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
23
30
  end
24
31
 
32
+ # Returns detailed information about a workflow.
33
+ # @param name [String] workflow name
34
+ # @return [Hash] `{ name:, desc:, params:, steps: }`
25
35
  def describe_workflow(name)
26
36
  defn = fetch_workflow(name)
27
37
  { name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:label) }
28
38
  end
29
39
 
40
+ SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
41
+
30
42
  private
31
43
 
44
+ def validate_name!(name)
45
+ return if SAFE_WORKFLOW_NAME.match?(name.to_s)
46
+
47
+ raise Browserctl::WorkflowError, "invalid workflow name: #{name.inspect} — use letters, digits, _ and - only"
48
+ end
49
+
32
50
  def fetch_workflow(name)
51
+ validate_name!(name)
33
52
  load_workflow_file(name)
34
53
  REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
35
54
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "snapshot_builder"
4
+ require_relative "page_session"
4
5
 
5
6
  module Browserctl
6
7
  class CommandDispatcher
@@ -18,68 +19,92 @@ module Browserctl
18
19
  "watch" => :cmd_watch,
19
20
  "url" => :cmd_url,
20
21
  "ping" => :cmd_ping,
21
- "shutdown" => :cmd_shutdown
22
+ "shutdown" => :cmd_shutdown,
23
+ "pause" => :cmd_pause,
24
+ "resume" => :cmd_resume,
25
+ "inspect" => :cmd_inspect,
26
+ "cookies" => :cmd_cookies,
27
+ "set_cookie" => :cmd_set_cookie,
28
+ "clear_cookies" => :cmd_clear_cookies
22
29
  }.freeze
23
30
 
24
- def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, mutex: Mutex.new)
25
- @pages = pages
26
- @browser = browser
27
- @snapshot = snapshot_builder
28
- @mutex = mutex
29
- @ref_registries = {}
30
- @prev_snapshots = {}
31
+ SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
32
+ SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
33
+ CLOUDFLARE_SIGNALS = [
34
+ "cf-challenge-running",
35
+ "cf_chl_opt",
36
+ "__cf_chl_f_tk",
37
+ "Just a moment..."
38
+ ].freeze
39
+
40
+ def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
41
+ @pages = pages
42
+ @browser = browser
43
+ @snapshot_builder = snapshot_builder
44
+ @global_mutex = global_mutex
31
45
  end
32
46
 
33
47
  def dispatch(req)
34
48
  handler = COMMAND_MAP[req[:cmd]]
35
- return { error: "unknown command: #{req[:cmd]}" } unless handler
49
+ if handler
50
+ Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
51
+ return send(handler, req)
52
+ end
53
+
54
+ if (plugin = Browserctl::PLUGIN_COMMANDS[req[:cmd]])
55
+ Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
56
+ session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
57
+ return plugin.call(session, req)
58
+ end
36
59
 
37
- Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
38
- send(handler, req)
60
+ { error: "unknown command: #{req[:cmd]}" }
39
61
  end
40
62
 
41
63
  private
42
64
 
43
65
  def cmd_open_page(req)
44
- page = @mutex.synchronize { @pages[req[:name]] ||= @browser.create_page }
45
- page.go_to(req[:url]) if req[:url]
66
+ session = @global_mutex.synchronize do
67
+ @pages[req[:name]] ||= PageSession.new(@browser.create_page)
68
+ end
69
+ session.page.go_to(req[:url]) if req[:url]
46
70
  { ok: true, name: req[:name] }
47
71
  end
48
72
 
49
73
  def cmd_close_page(req)
50
- @mutex.synchronize { @pages.delete(req[:name]) }&.close
74
+ session = @global_mutex.synchronize { @pages.delete(req[:name]) }
75
+ session&.page&.close
51
76
  { ok: true }
52
77
  end
53
78
 
54
79
  def cmd_list_pages(_req)
55
- { pages: @mutex.synchronize { @pages.keys } }
80
+ { pages: @global_mutex.synchronize { @pages.keys } }
56
81
  end
57
82
 
58
83
  def cmd_goto(req)
59
- with_page(req[:name]) do |p|
60
- p.go_to(req[:url])
61
- { ok: true, url: p.current_url }
84
+ with_page(req[:name]) do |session|
85
+ session.page.go_to(req[:url])
86
+ { ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
62
87
  end
63
88
  end
64
89
 
65
90
  def cmd_snapshot(req)
66
- with_page(req[:name]) { |p| take_snapshot(req[:name], p, req[:format], req[:diff]) }
91
+ with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
67
92
  end
68
93
 
69
- def take_snapshot(name, page, format, diff)
70
- return { ok: true, html: page.body } unless format == "ai"
94
+ def take_snapshot(session, format, diff)
95
+ challenge = cloudflare_challenge?(session.page)
96
+
97
+ return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
71
98
 
72
- snapshot = @snapshot.call(page)
99
+ snapshot = @snapshot_builder.call(session.page)
73
100
  registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
74
101
 
75
- result = @mutex.synchronize do
76
- prev = @prev_snapshots[name]
77
- @ref_registries[name] = registry
78
- @prev_snapshots[name] = snapshot
79
- diff && prev ? compute_diff(prev, snapshot) : snapshot
80
- end
102
+ prev = session.prev_snapshot
103
+ session.ref_registry = registry
104
+ session.prev_snapshot = snapshot
105
+ result = diff && prev ? compute_diff(prev, snapshot) : snapshot
81
106
 
82
- { ok: true, snapshot: result }
107
+ { ok: true, snapshot: result, challenge: challenge }
83
108
  end
84
109
 
85
110
  def compute_diff(prev, current)
@@ -91,14 +116,16 @@ module Browserctl
91
116
  end
92
117
 
93
118
  def cmd_evaluate(req)
94
- with_page(req[:name]) { |p| { ok: true, result: p.evaluate(req[:expression]) } }
119
+ with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
95
120
  end
96
121
 
97
122
  def cmd_fill(req)
98
- sel = resolve_selector(req[:name], req)
99
- return sel if sel.is_a?(Hash)
123
+ with_page(req[:name]) do |session|
124
+ sel = resolve_selector_from(session, req)
125
+ return sel if sel.is_a?(Hash)
100
126
 
101
- with_page(req[:name]) { |p| type_into(p, sel, req[:value]) }
127
+ type_into(session.page, sel, req[:value])
128
+ end
102
129
  end
103
130
 
104
131
  def type_into(page, selector, value)
@@ -111,10 +138,12 @@ module Browserctl
111
138
  end
112
139
 
113
140
  def cmd_click(req)
114
- sel = resolve_selector(req[:name], req)
115
- return sel if sel.is_a?(Hash)
141
+ with_page(req[:name]) do |session|
142
+ sel = resolve_selector_from(session, req)
143
+ return sel if sel.is_a?(Hash)
116
144
 
117
- with_page(req[:name]) { |p| click_element(p, sel) }
145
+ click_element(session.page, sel)
146
+ end
118
147
  end
119
148
 
120
149
  def click_element(page, selector)
@@ -126,56 +155,145 @@ module Browserctl
126
155
  end
127
156
 
128
157
  def cmd_screenshot(req)
129
- with_page(req[:name]) do |p|
130
- path = req[:path] || "/tmp/browserctl_shot_#{req[:name]}_#{Time.now.to_i}.png"
131
- p.screenshot(path: path, full: req.fetch(:full, false))
158
+ with_page(req[:name]) do |session|
159
+ path = safe_screenshot_path(req[:path], req[:name])
160
+ return path if path.is_a?(Hash)
161
+
162
+ FileUtils.mkdir_p(File.dirname(path))
163
+ session.page.screenshot(path: path, full: req.fetch(:full, false))
132
164
  { ok: true, path: path }
133
165
  end
134
166
  end
135
167
 
168
+ def safe_screenshot_path(requested, page_name)
169
+ if requested
170
+ expanded = File.expand_path(requested)
171
+ return { error: "path outside allowed directory (#{SCREENSHOT_DIR})" } \
172
+ unless expanded.start_with?(SCREENSHOT_DIR)
173
+ return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
174
+ unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
175
+
176
+ expanded
177
+ else
178
+ name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
+ File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
180
+ end
181
+ end
182
+
136
183
  def cmd_wait_for(req)
137
- with_page(req[:name]) { |p| wait_for_selector(p, req[:selector], req.fetch(:timeout, 10).to_f) }
184
+ with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
138
185
  end
139
186
 
140
187
  def cmd_watch(req)
141
- with_page(req[:name]) do |p|
142
- result = wait_for_selector(p, req[:selector], req.fetch(:timeout, 30).to_f)
188
+ with_page(req[:name]) do |session|
189
+ result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
143
190
  result[:error] ? result : { ok: true, selector: req[:selector] }
144
191
  end
145
192
  end
146
193
 
147
194
  def wait_for_selector(page, selector, timeout)
148
195
  deadline = Time.now + timeout
149
- sleep 0.2 until (found = page.at_css(selector)) || Time.now > deadline
150
- found ? { ok: true } : { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" }
196
+ loop do
197
+ found = page.at_css(selector)
198
+ break { ok: true } if found
199
+ break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
200
+
201
+ sleep 0.2
202
+ end
151
203
  end
152
204
 
153
205
  def cmd_url(req)
154
- with_page(req[:name]) { |p| { ok: true, url: p.current_url } }
206
+ with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
155
207
  end
156
208
 
157
- def cmd_ping(_req)
158
- { ok: true, pid: Process.pid }
209
+ def cmd_cookies(req)
210
+ session = @global_mutex.synchronize { @pages[req[:name]] }
211
+ return { error: "no page named '#{req[:name]}'" } unless session
212
+
213
+ all = session.page.cookies.all
214
+ { ok: true, cookies: all.values.map(&:to_h) }
215
+ end
216
+
217
+ def cmd_set_cookie(req)
218
+ session = @global_mutex.synchronize { @pages[req[:name]] }
219
+ return { error: "no page named '#{req[:name]}'" } unless session
220
+
221
+ session.page.cookies.set(
222
+ name: req[:cookie_name],
223
+ value: req[:value],
224
+ domain: req[:domain],
225
+ path: req.fetch(:path, "/")
226
+ )
227
+ { ok: true }
159
228
  end
160
229
 
230
+ def cmd_clear_cookies(req)
231
+ session = @global_mutex.synchronize { @pages[req[:name]] }
232
+ return { error: "no page named '#{req[:name]}'" } unless session
233
+
234
+ session.page.cookies.clear
235
+ { ok: true }
236
+ end
237
+
238
+ def cmd_inspect(req)
239
+ session = @global_mutex.synchronize { @pages[req[:name]] }
240
+ return { error: "no page named '#{req[:name]}'" } unless session
241
+
242
+ port = @browser.process.port
243
+ target_id = session.page.target_id
244
+ devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
245
+ "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
246
+ { ok: true, devtools_url: devtools_url }
247
+ end
248
+
249
+ def cmd_pause(req)
250
+ session = @global_mutex.synchronize { @pages[req[:name]] }
251
+ return { error: "no page named '#{req[:name]}'" } unless session
252
+
253
+ session.mutex.synchronize { session.pause! }
254
+ { ok: true, paused: true }
255
+ end
256
+
257
+ def cmd_resume(req)
258
+ session = @global_mutex.synchronize { @pages[req[:name]] }
259
+ return { error: "no page named '#{req[:name]}'" } unless session
260
+
261
+ session.mutex.synchronize do
262
+ session.resume!
263
+ session.pause_cv.signal
264
+ end
265
+ { ok: true, paused: false }
266
+ end
267
+
268
+ def cmd_ping(_req) = { ok: true, pid: Process.pid }
269
+
161
270
  def cmd_shutdown(_req)
162
271
  Process.kill("INT", Process.pid)
163
272
  { ok: true }
164
273
  end
165
274
 
166
275
  def with_page(name)
167
- page = @mutex.synchronize { @pages[name] }
168
- return { error: "no page named '#{name}'" } unless page
276
+ session = @global_mutex.synchronize { @pages[name] }
277
+ return { error: "no page named '#{name}'" } unless session
278
+
279
+ session.mutex.synchronize do
280
+ session.pause_cv.wait(session.mutex) while session.paused?
281
+ yield session
282
+ end
283
+ end
169
284
 
170
- yield page
285
+ def cloudflare_challenge?(page)
286
+ url = page.current_url.to_s
287
+ body = page.body.to_s
288
+ url.include?("challenge-platform") ||
289
+ CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
171
290
  end
172
291
 
173
- def resolve_selector(name, req)
292
+ def resolve_selector_from(session, req)
174
293
  return req[:selector] if req[:selector]
175
294
  return { error: "selector or ref required" } unless req[:ref]
176
295
 
177
- sel = @mutex.synchronize { @ref_registries.dig(name, req[:ref]) }
178
- sel || { error: "ref '#{req[:ref]}' not found — run snap first" }
296
+ session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
179
297
  end
180
298
  end
181
299
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class PageSession
5
+ attr_reader :page, :mutex, :pause_cv
6
+ attr_accessor :ref_registry, :prev_snapshot
7
+
8
+ def initialize(page)
9
+ @page = page
10
+ @mutex = Mutex.new
11
+ @pause_cv = ConditionVariable.new
12
+ @ref_registry = {}
13
+ @prev_snapshot = nil
14
+ @paused = false
15
+ end
16
+
17
+ def paused? = @paused
18
+ def pause! = (@paused = true)
19
+ def resume! = (@paused = false)
20
+ end
21
+ end
@@ -9,6 +9,7 @@ require_relative "constants"
9
9
  require_relative "logger"
10
10
  require_relative "server/command_dispatcher"
11
11
  require_relative "server/idle_watcher"
12
+ require_relative "server/page_session"
12
13
 
13
14
  module Browserctl
14
15
  class Server
@@ -16,7 +17,7 @@ module Browserctl
16
17
  @socket_path = socket_path
17
18
  @pid_path = pid_path
18
19
  prepare_runtime(headless)
19
- @dispatcher = CommandDispatcher.new(@pages, @browser, mutex: @mutex)
20
+ @dispatcher = CommandDispatcher.new(@pages, @browser, global_mutex: @mutex)
20
21
  end
21
22
 
22
23
  def run
@@ -85,7 +86,6 @@ module Browserctl
85
86
  def dispatch(socket, line)
86
87
  return unless line
87
88
 
88
- @mutex.synchronize { @last_used = Time.now }
89
89
  socket.puts JSON.generate(process(line))
90
90
  end
91
91
 
@@ -109,7 +109,7 @@ module Browserctl
109
109
 
110
110
  def quietly
111
111
  yield
112
- rescue StandardError
112
+ rescue Exception # rubocop:disable Lint/RescueException
113
113
  nil
114
114
  end
115
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -112,6 +112,13 @@ module Browserctl
112
112
  @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
113
113
  end
114
114
 
115
+ def compose(workflow_name)
116
+ source = REGISTRY[workflow_name.to_s]
117
+ raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
118
+
119
+ @steps.concat(source.steps)
120
+ end
121
+
115
122
  def call(params, client)
116
123
  ctx = WorkflowContext.new(resolve_params(params), client)
117
124
  execute_steps(ctx)