browserctl 0.3.1 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +120 -214
  4. data/bin/browserctl +35 -13
  5. data/bin/browserd +7 -1
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +1 -1
  8. data/examples/smoke/params_file.rb +35 -0
  9. data/examples/smoke/store_fetch.rb +39 -0
  10. data/examples/the_internet/add_remove_elements.rb +3 -3
  11. data/examples/the_internet/checkboxes.rb +3 -3
  12. data/examples/the_internet/dropdown.rb +3 -3
  13. data/examples/the_internet/dynamic_loading.rb +3 -3
  14. data/examples/the_internet/login.rb +5 -5
  15. data/lib/browserctl/client.rb +38 -2
  16. data/lib/browserctl/commands/export_cookies.rb +18 -0
  17. data/lib/browserctl/commands/import_cookies.rb +23 -0
  18. data/lib/browserctl/commands/init.rb +11 -0
  19. data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
  20. data/lib/browserctl/commands/record.rb +2 -0
  21. data/lib/browserctl/commands/resume.rb +21 -0
  22. data/lib/browserctl/commands/snapshot.rb +5 -5
  23. data/lib/browserctl/commands/status.rb +30 -0
  24. data/lib/browserctl/constants.rb +9 -2
  25. data/lib/browserctl/detectors.rb +23 -0
  26. data/lib/browserctl/errors.rb +25 -0
  27. data/lib/browserctl/logger.rb +40 -5
  28. data/lib/browserctl/policy.rb +36 -0
  29. data/lib/browserctl/recording.rb +81 -15
  30. data/lib/browserctl/runner.rb +23 -4
  31. data/lib/browserctl/server/command_dispatcher.rb +31 -234
  32. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  33. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  34. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  35. data/lib/browserctl/server/handlers/hitl.rb +30 -0
  36. data/lib/browserctl/server/handlers/navigation.rb +72 -0
  37. data/lib/browserctl/server/handlers/observation.rb +113 -0
  38. data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
  39. data/lib/browserctl/server.rb +18 -2
  40. data/lib/browserctl/version.rb +1 -1
  41. data/lib/browserctl/workflow.rb +41 -3
  42. data/lib/browserctl.rb +12 -2
  43. metadata +48 -4
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Navigation
7
+ private
8
+
9
+ def cmd_goto(req)
10
+ unless Policy.allowed_navigation?(req[:url].to_s)
11
+ return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
12
+ end
13
+
14
+ with_page(req[:name]) do |session|
15
+ session.page.go_to(req[:url])
16
+ { ok: true, url: session.page.current_url, challenge: Detectors.cloudflare?(session.page) }
17
+ end
18
+ end
19
+
20
+ def cmd_evaluate(req)
21
+ with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
22
+ end
23
+
24
+ def cmd_fill(req)
25
+ with_page(req[:name]) do |session|
26
+ sel = resolve_selector_from(session, req)
27
+ return sel if sel.is_a?(Hash)
28
+
29
+ type_into(session.page, sel, req[:value])
30
+ end
31
+ end
32
+
33
+ def cmd_click(req)
34
+ with_page(req[:name]) do |session|
35
+ sel = resolve_selector_from(session, req)
36
+ return sel if sel.is_a?(Hash)
37
+
38
+ click_element(session.page, sel)
39
+ end
40
+ end
41
+
42
+ def cmd_url(req)
43
+ with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
44
+ end
45
+
46
+ def type_into(page, selector, value)
47
+ el = page.at_css(selector)
48
+ return { error: "selector not found: #{selector}" } unless el
49
+
50
+ el.focus
51
+ el.type(value)
52
+ { ok: true }
53
+ end
54
+
55
+ def click_element(page, selector)
56
+ el = page.at_css(selector)
57
+ return { error: "selector not found: #{selector}" } unless el
58
+
59
+ el.click
60
+ { ok: true }
61
+ end
62
+
63
+ def resolve_selector_from(session, req)
64
+ return req[:selector] if req[:selector]
65
+ return { error: "selector or ref required" } unless req[:ref]
66
+
67
+ session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ module Observation
9
+ private
10
+
11
+ def cmd_snapshot(req)
12
+ with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
13
+ end
14
+
15
+ def take_snapshot(session, format, diff)
16
+ nonce = SecureRandom.hex(8)
17
+ challenge = Detectors.cloudflare?(session.page)
18
+
19
+ return { ok: true, html: session.page.body, challenge: challenge, nonce: nonce } unless format == "elements"
20
+
21
+ snapshot = @snapshot_builder.call(session.page)
22
+ registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
23
+
24
+ prev = session.prev_snapshot
25
+ session.ref_registry = registry
26
+ session.prev_snapshot = snapshot
27
+ result = diff && prev ? compute_diff(prev, snapshot) : snapshot
28
+
29
+ { ok: true, snapshot: result, challenge: challenge, nonce: nonce }
30
+ end
31
+
32
+ def compute_diff(prev, current)
33
+ prev_by_sel = prev.to_h { |el| [el[:selector], el] }
34
+ current.reject do |el|
35
+ old = prev_by_sel[el[:selector]]
36
+ old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
37
+ end
38
+ end
39
+
40
+ def cmd_screenshot(req)
41
+ with_page(req[:name]) do |session|
42
+ path = safe_screenshot_path(req[:path], req[:name])
43
+ return path if path.is_a?(Hash)
44
+
45
+ FileUtils.mkdir_p(File.dirname(path))
46
+ session.page.screenshot(path: path, full: req.fetch(:full, false))
47
+ { ok: true, path: path }
48
+ end
49
+ end
50
+
51
+ def safe_screenshot_path(requested, page_name)
52
+ return default_screenshot_path(page_name) unless requested
53
+
54
+ expanded = File.expand_path(requested)
55
+ return { error: "invalid extension — use .png, .jpg, or .jpeg" } unless valid_screenshot_ext?(expanded)
56
+ return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } \
57
+ unless within_screenshot_roots?(expanded)
58
+
59
+ File.join(resolve_dir(File.dirname(expanded)), File.basename(expanded))
60
+ end
61
+
62
+ def valid_screenshot_ext?(path)
63
+ SCREENSHOT_EXTS.include?(File.extname(path).downcase)
64
+ end
65
+
66
+ def within_screenshot_roots?(path)
67
+ dir = resolve_dir(File.dirname(path))
68
+ SCREENSHOT_ROOTS.any? do |root|
69
+ real_root = resolve_dir(root)
70
+ dir.start_with?("#{real_root}/") || dir == real_root
71
+ end
72
+ end
73
+
74
+ def resolve_dir(dir)
75
+ File.realpath(dir)
76
+ rescue Errno::ENOENT
77
+ dir
78
+ end
79
+
80
+ def default_screenshot_path(page_name)
81
+ name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
82
+ File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
83
+ end
84
+
85
+ def cmd_wait_for(req)
86
+ with_page(req[:name]) do |session|
87
+ wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f)
88
+ end
89
+ end
90
+
91
+ def cmd_watch(req)
92
+ with_page(req[:name]) do |session|
93
+ result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
94
+ result[:error] ? result : { ok: true, selector: req[:selector] }
95
+ end
96
+ end
97
+
98
+ def wait_for_selector(page, selector, timeout)
99
+ deadline = Time.now + timeout
100
+ loop do
101
+ found = page.at_css(selector)
102
+ break { ok: true } if found
103
+ if Time.now >= deadline
104
+ break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" }
105
+ end
106
+
107
+ sleep 0.2
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module PageLifecycle
7
+ private
8
+
9
+ def cmd_open_page(req)
10
+ session = @global_mutex.synchronize do
11
+ @pages[req[:name]] ||= PageSession.new(@browser.create_page)
12
+ end
13
+ session.page.go_to(req[:url]) if req[:url]
14
+ { ok: true, name: req[:name] }
15
+ end
16
+
17
+ def cmd_close_page(req)
18
+ session = @global_mutex.synchronize { @pages.delete(req[:name]) }
19
+ session&.page&.close
20
+ { ok: true }
21
+ end
22
+
23
+ def cmd_list_pages(_req)
24
+ { pages: @global_mutex.synchronize { @pages.keys } }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -21,6 +21,7 @@ module Browserctl
21
21
  end
22
22
 
23
23
  def run
24
+ guard_already_running!
24
25
  write_pid
25
26
  server, idle = setup_server
26
27
  serve(server)
@@ -61,12 +62,27 @@ module Browserctl
61
62
 
62
63
  def setup_socket
63
64
  FileUtils.rm_f(@socket_path)
65
+ old_umask = File.umask(0o177)
64
66
  server = UNIXServer.new(@socket_path)
65
- File.chmod(0o600, @socket_path)
66
- Browserctl.logger.info "listening on #{@socket_path}"
67
+ File.umask(old_umask)
68
+ Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
67
69
  server
68
70
  end
69
71
 
72
+ def guard_already_running!
73
+ return unless File.exist?(@pid_path)
74
+
75
+ pid = File.read(@pid_path).strip.to_i
76
+ return unless pid.positive?
77
+
78
+ Process.kill(0, pid)
79
+ abort "browserd already running (PID #{pid}). Use 'browserctl shutdown' first."
80
+ rescue Errno::ESRCH
81
+ # Dead process — stale PID file, safe to continue
82
+ rescue Errno::EPERM
83
+ abort "browserd (PID #{pid}) is running as a different user. Remove #{@pid_path} manually if stale."
84
+ end
85
+
70
86
  def serve(server)
71
87
  loop do
72
88
  client = server.accept
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.3.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -18,6 +18,20 @@ module Browserctl
18
18
  @client = client
19
19
  end
20
20
 
21
+ def store(key, value)
22
+ res = @client.store(key.to_s, value)
23
+ raise WorkflowError, res[:error] if res[:error]
24
+
25
+ value
26
+ end
27
+
28
+ def fetch(key)
29
+ res = @client.fetch(key.to_s)
30
+ raise WorkflowError, res[:error] if res[:error]
31
+
32
+ res[:value]
33
+ end
34
+
21
35
  def method_missing(name, *args)
22
36
  return @params[name] if @params.key?(name)
23
37
 
@@ -32,6 +46,20 @@ module Browserctl
32
46
  PageProxy.new(name.to_s, @client)
33
47
  end
34
48
 
49
+ def open_page(page_name, url: nil)
50
+ res = @client.open_page(page_name.to_s, url: url)
51
+ raise WorkflowError, res[:error] if res[:error]
52
+
53
+ res
54
+ end
55
+
56
+ def close_page(page_name)
57
+ res = @client.close_page(page_name.to_s)
58
+ raise WorkflowError, res[:error] if res[:error]
59
+
60
+ res
61
+ end
62
+
35
63
  def invoke(workflow_name, **override_params)
36
64
  name = workflow_name.to_s
37
65
  guard_circular!(name)
@@ -77,6 +105,7 @@ module Browserctl
77
105
  def click(sel) = unwrap @client.click(@name, sel)
78
106
  def snapshot(**) = unwrap @client.snapshot(@name, **)
79
107
  def screenshot(**) = unwrap @client.screenshot(@name, **)
108
+ def watch(sel, timeout: 30) = unwrap @client.watch(@name, sel, timeout: timeout)
80
109
  def wait_for(sel, timeout: 10) = unwrap @client.wait_for(@name, sel, timeout: timeout)
81
110
  def url = @client.url(@name)[:url]
82
111
  def evaluate(expr) = @client.evaluate(@name, expr)[:result]
@@ -113,7 +142,7 @@ module Browserctl
113
142
  end
114
143
 
115
144
  def compose(workflow_name)
116
- source = REGISTRY[workflow_name.to_s]
145
+ source = Browserctl.lookup_workflow(workflow_name.to_s)
117
146
  raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
118
147
 
119
148
  @steps.concat(source.steps)
@@ -163,11 +192,20 @@ module Browserctl
163
192
  end
164
193
  end
165
194
 
166
- REGISTRY = {} # rubocop:disable Style/MutableConstant
195
+ @registry_mutex = Mutex.new
196
+ @registry = {}
167
197
 
168
198
  def self.workflow(name, &)
169
199
  defn = WorkflowDefinition.new(name.to_s)
170
200
  defn.instance_exec(&)
171
- REGISTRY[name.to_s] = defn
201
+ @registry_mutex.synchronize { @registry[name.to_s] = defn }
202
+ end
203
+
204
+ def self.lookup_workflow(name)
205
+ @registry_mutex.synchronize { @registry[name.to_s] }
206
+ end
207
+
208
+ def self.registry_snapshot
209
+ @registry_mutex.synchronize { @registry.dup }
172
210
  end
173
211
  end
data/lib/browserctl.rb CHANGED
@@ -2,14 +2,24 @@
2
2
 
3
3
  require_relative "browserctl/version"
4
4
  require_relative "browserctl/constants"
5
+ require_relative "browserctl/errors"
5
6
  require_relative "browserctl/workflow"
6
7
  require_relative "browserctl/runner"
7
8
  require_relative "browserctl/client"
8
9
 
9
10
  module Browserctl
10
- PLUGIN_COMMANDS = {} # rubocop:disable Style/MutableConstant
11
+ @plugin_commands_mutex = Mutex.new
12
+ @plugin_commands = {}
11
13
 
12
14
  def self.register_command(name, &block)
13
- PLUGIN_COMMANDS[name.to_s] = block
15
+ @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] = block }
16
+ end
17
+
18
+ def self.lookup_plugin_command(name)
19
+ @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] }
20
+ end
21
+
22
+ def self.plugin_commands_snapshot
23
+ @plugin_commands_mutex.synchronize { @plugin_commands.dup }
14
24
  end
15
25
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-20 00:00:00.000000000 Z
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ferrum
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: lefthook
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.11'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.11'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: rspec
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -111,6 +139,8 @@ files:
111
139
  - bin/browserd
112
140
  - bin/setup
113
141
  - examples/cloudflare_hitl.rb
142
+ - examples/smoke/params_file.rb
143
+ - examples/smoke/store_fetch.rb
114
144
  - examples/the_internet/add_remove_elements.rb
115
145
  - examples/the_internet/checkboxes.rb
116
146
  - examples/the_internet/dropdown.rb
@@ -120,21 +150,35 @@ files:
120
150
  - lib/browserctl/client.rb
121
151
  - lib/browserctl/commands/cli_output.rb
122
152
  - lib/browserctl/commands/click.rb
153
+ - lib/browserctl/commands/export_cookies.rb
123
154
  - lib/browserctl/commands/fill.rb
155
+ - lib/browserctl/commands/import_cookies.rb
124
156
  - lib/browserctl/commands/init.rb
125
157
  - lib/browserctl/commands/inspect.rb
126
158
  - lib/browserctl/commands/open_page.rb
127
- - lib/browserctl/commands/pause_resume.rb
159
+ - lib/browserctl/commands/pause.rb
128
160
  - lib/browserctl/commands/record.rb
161
+ - lib/browserctl/commands/resume.rb
129
162
  - lib/browserctl/commands/screenshot.rb
130
163
  - lib/browserctl/commands/snapshot.rb
164
+ - lib/browserctl/commands/status.rb
131
165
  - lib/browserctl/commands/watch.rb
132
166
  - lib/browserctl/constants.rb
167
+ - lib/browserctl/detectors.rb
168
+ - lib/browserctl/errors.rb
133
169
  - lib/browserctl/logger.rb
170
+ - lib/browserctl/policy.rb
134
171
  - lib/browserctl/recording.rb
135
172
  - lib/browserctl/runner.rb
136
173
  - lib/browserctl/server.rb
137
174
  - lib/browserctl/server/command_dispatcher.rb
175
+ - lib/browserctl/server/handlers/cookies.rb
176
+ - lib/browserctl/server/handlers/daemon_control.rb
177
+ - lib/browserctl/server/handlers/devtools.rb
178
+ - lib/browserctl/server/handlers/hitl.rb
179
+ - lib/browserctl/server/handlers/navigation.rb
180
+ - lib/browserctl/server/handlers/observation.rb
181
+ - lib/browserctl/server/handlers/page_lifecycle.rb
138
182
  - lib/browserctl/server/idle_watcher.rb
139
183
  - lib/browserctl/server/page_session.rb
140
184
  - lib/browserctl/server/snapshot_builder.rb
@@ -157,7 +201,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
157
201
  requirements:
158
202
  - - ">="
159
203
  - !ruby/object:Gem::Version
160
- version: '3.2'
204
+ version: '3.3'
161
205
  required_rubygems_version: !ruby/object:Gem::Requirement
162
206
  requirements:
163
207
  - - ">="