browserctl 0.3.0 → 0.4.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.
@@ -3,9 +3,10 @@
3
3
  Browserctl.workflow "the_internet/login" do
4
4
  desc "Form authentication: fill credentials, submit, assert secure area"
5
5
 
6
- param :username, default: "tomsmith"
7
- param :password, default: "SuperSecretPassword!", secret: true
8
- param :base_url, default: "https://the-internet.herokuapp.com"
6
+ param :username, default: "tomsmith"
7
+ param :password, default: "SuperSecretPassword!", secret: true
8
+ param :base_url, default: "https://the-internet.herokuapp.com"
9
+ param :screenshot_path, default: File.expand_path(".browserctl/screenshots/the_internet_login.png")
9
10
 
10
11
  step "open login page" do
11
12
  client.open_page("main", url: "#{base_url}/login")
@@ -21,8 +22,7 @@ Browserctl.workflow "the_internet/login" do
21
22
  assert page(:main).url.include?("/secure"), "expected redirect to /secure"
22
23
  flash = client.evaluate("main", "document.querySelector('.flash.success')?.innerText?.trim()")[:result]
23
24
  assert flash&.include?("You logged into a secure area!"), "expected success flash, got: #{flash.inspect}"
24
- screenshots_dir = File.expand_path("../../docs/screenshots", __dir__)
25
- page(:main).screenshot(path: "#{screenshots_dir}/the_internet_login.png")
25
+ page(:main).screenshot(path: screenshot_path)
26
26
  end
27
27
 
28
28
  step "logout and verify" do
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "socket"
4
5
  require "json"
5
6
  require_relative "constants"
@@ -152,6 +153,30 @@ module Browserctl
152
153
  # @return [Hash] `{ ok: true }` or `{ error: }`
153
154
  def clear_cookies(name) = call("clear_cookies", name: name)
154
155
 
156
+ # Exports all cookies for a named page to a JSON file.
157
+ # @param name [String] logical page name
158
+ # @param path [String] file path to write cookies to
159
+ # @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
160
+ def export_cookies(name, path)
161
+ result = call("cookies", name: name)
162
+ return result unless result[:ok]
163
+
164
+ FileUtils.mkdir_p(File.dirname(path))
165
+ File.open(path, "w", 0o600) { |f| f.write(JSON.generate(result[:cookies])) }
166
+ { ok: true, path: path, count: result[:cookies].length }
167
+ end
168
+
169
+ # Imports cookies from a JSON file into a named page.
170
+ # @param name [String] logical page name
171
+ # @param path [String] file path to read cookies from
172
+ # @return [Hash] `{ ok: true, count: }` or `{ error: }`
173
+ def import_cookies(name, path)
174
+ raise "cookie file not found: #{path}" unless File.exist?(path)
175
+
176
+ cookies = JSON.parse(File.read(path), symbolize_names: true)
177
+ call("import_cookies", name: name, cookies: cookies)
178
+ end
179
+
155
180
  private
156
181
 
157
182
  def communicate(payload)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ class ExportCookies
6
+ def self.run(client, args)
7
+ page = args.shift or abort "usage: browserctl export-cookies <page> <path>"
8
+ path = args.shift or abort "usage: browserctl export-cookies <page> <path>"
9
+ result = client.export_cookies(page, path)
10
+ if result[:error]
11
+ warn "Error: #{result[:error]}"
12
+ exit 1
13
+ end
14
+ puts result.to_json
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ class ImportCookies
6
+ def self.run(client, args)
7
+ page = args.shift or abort "usage: browserctl import-cookies <page> <path>"
8
+ path = args.shift or abort "usage: browserctl import-cookies <page> <path>"
9
+ begin
10
+ result = client.import_cookies(page, path)
11
+ rescue StandardError => e
12
+ warn "Error: #{e.message}"
13
+ exit 1
14
+ end
15
+ if result[:error]
16
+ warn "Error: #{result[:error]}"
17
+ exit 1
18
+ end
19
+ puts result.to_json
20
+ end
21
+ end
22
+ end
23
+ end
@@ -13,15 +13,26 @@ module Browserctl
13
13
  # workflows_dir: .browserctl/workflows
14
14
  YAML
15
15
 
16
+ GITIGNORE_CONTENT = <<~GITIGNORE
17
+ # Cookie session exports — contain credentials, never commit
18
+ sessions/
19
+ GITIGNORE
20
+
16
21
  def self.run(_args)
17
22
  FileUtils.mkdir_p(".browserctl/workflows")
18
23
  FileUtils.touch(".browserctl/workflows/.keep")
19
24
 
25
+ FileUtils.mkdir_p(".browserctl/sessions")
26
+
27
+ gitignore_path = ".browserctl/.gitignore"
28
+ File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
29
+
20
30
  config_path = ".browserctl/config.yml"
21
31
  File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
22
32
 
23
33
  puts "Initialised browserctl project:"
24
34
  puts " .browserctl/workflows/ (place workflow .rb files here)"
35
+ puts " .browserctl/sessions/ (cookie exports — git-ignored)"
25
36
  puts " .browserctl/config.yml (project settings)"
26
37
  end
27
38
  end
@@ -4,10 +4,10 @@ require_relative "cli_output"
4
4
 
5
5
  module Browserctl
6
6
  module Commands
7
- module PauseResume
7
+ module Pause
8
8
  extend CliOutput
9
9
 
10
- def self.pause(client, args)
10
+ def self.run(client, args)
11
11
  name = args.shift or abort "usage: browserctl pause <page>"
12
12
  res = client.pause(name)
13
13
  if res[:error]
@@ -17,16 +17,6 @@ module Browserctl
17
17
  puts "Page '#{name}' paused. Browser is live — interact freely."
18
18
  puts "When done: browserctl resume #{name}"
19
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
20
  end
31
21
  end
32
22
  end
@@ -26,6 +26,8 @@ module Browserctl
26
26
  def run_start(args)
27
27
  Optimist.options(args) { banner "Usage: browserctl record start <name>" }
28
28
  name = args.shift or abort "usage: browserctl record start <name>"
29
+ abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
30
+ unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
29
31
  Recording.start(name)
30
32
  puts "Recording started: #{name}"
31
33
  puts "Run browser commands, then: browserctl record stop"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Resume
8
+ extend CliOutput
9
+
10
+ def self.run(client, args)
11
+ name = args.shift or abort "usage: browserctl resume <page>"
12
+ res = client.resume(name)
13
+ if res[:error]
14
+ warn "Error: #{res[:error]}"
15
+ exit 1
16
+ end
17
+ puts "Page '#{name}' resumed."
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Status
8
+ def self.run(client)
9
+ ping = client.ping
10
+ pages = client.list_pages[:pages] || []
11
+ page_info = pages.map do |name|
12
+ url_res = client.url(name)
13
+ { name: name, url: url_res[:url] || url_res[:error] }
14
+ end
15
+
16
+ puts JSON.pretty_generate(
17
+ daemon: "online",
18
+ pid: ping[:pid],
19
+ protocol_version: ping[:protocol_version],
20
+ pages: page_info
21
+ )
22
+ rescue RuntimeError => e
23
+ raise unless e.message.include?("browserd is not running")
24
+
25
+ puts JSON.pretty_generate(daemon: "offline", error: e.message)
26
+ exit 1
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
- IDLE_TTL = 30 * 60
4
+ BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
+ IDLE_TTL = 30 * 60
6
+ # Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
7
+ # Clients read this from `ping` to verify compatibility before sending commands.
8
+ PROTOCOL_VERSION = "1"
6
9
 
7
10
  def self.socket_path(name = nil)
8
11
  File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
@@ -12,6 +15,10 @@ module Browserctl
12
15
  File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
13
16
  end
14
17
 
18
+ def self.log_path(name = nil)
19
+ File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
20
+ end
21
+
15
22
  # Backward-compatible constants
16
23
  SOCKET_PATH = socket_path
17
24
  PID_PATH = pid_path
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "fileutils"
4
5
 
5
6
  module Browserctl
6
7
  LEVEL_MAP = {
@@ -10,6 +11,25 @@ module Browserctl
10
11
  "error" => ::Logger::ERROR
11
12
  }.freeze
12
13
 
14
+ class MultiLogger
15
+ def initialize(*loggers)
16
+ @loggers = loggers
17
+ end
18
+
19
+ # Delegate to each logger; swallow individual write failures so a broken file
20
+ # logger never crashes the daemon or drops a client response.
21
+ def debug(msg = nil, &) = @loggers.each { |l| l.debug(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
22
+ def info(msg = nil, &) = @loggers.each { |l| l.info(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
23
+ def warn(msg = nil, &) = @loggers.each { |l| l.warn(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
24
+ def error(msg = nil, &) = @loggers.each { |l| l.error(msg, &) rescue nil } # rubocop:disable Style/RescueModifier
25
+
26
+ def level = @loggers.first&.level
27
+
28
+ def level=(lvl)
29
+ @loggers.each { |l| l.level = lvl }
30
+ end
31
+ end
32
+
13
33
  def self.logger
14
34
  @logger ||= build_logger("info")
15
35
  end
@@ -18,11 +38,26 @@ module Browserctl
18
38
  @logger = instance
19
39
  end
20
40
 
21
- def self.build_logger(level_name)
22
- log = ::Logger.new($stderr)
23
- log.level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
24
- log.progname = "browserd"
25
- log.formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
41
+ def self.build_logger(level_name, log_path: nil)
42
+ level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
43
+ formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
44
+
45
+ stderr_log = make_logger($stderr, level, formatter)
46
+ return stderr_log unless log_path
47
+
48
+ FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
49
+ FileUtils.touch(log_path)
50
+ File.chmod(0o600, log_path)
51
+ file_log = make_logger(log_path, level, formatter)
52
+ MultiLogger.new(stderr_log, file_log)
53
+ end
54
+
55
+ def self.make_logger(device, level, formatter)
56
+ log = ::Logger.new(device)
57
+ log.level = level
58
+ log.progname = "browserd"
59
+ log.formatter = formatter
26
60
  log
27
61
  end
62
+ private_class_method :make_logger
28
63
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "date"
5
5
  require "fileutils"
6
6
  require "tmpdir"
7
+ require "uri"
7
8
 
8
9
  module Browserctl
9
10
  class Recording
@@ -12,11 +13,15 @@ module Browserctl
12
13
 
13
14
  RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
14
15
 
16
+ SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
17
+
15
18
  def self.start(name)
16
- FileUtils.mkdir_p(RECORDINGS_DIR)
19
+ FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
17
20
  FileUtils.mkdir_p(File.dirname(STATE_FILE))
18
21
  File.write(STATE_FILE, name)
19
22
  FileUtils.rm_f(log_path(name))
23
+ FileUtils.touch(log_path(name))
24
+ File.chmod(0o600, log_path(name))
20
25
  name
21
26
  end
22
27
 
@@ -36,10 +41,13 @@ module Browserctl
36
41
  name = active
37
42
  return unless name
38
43
  return unless RECORDABLE.include?(cmd.to_s)
39
- # ref-based interactions have no replayable selector — skip them
40
- return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
41
44
 
42
- attrs = attrs.except(:value) if cmd.to_s == "fill"
45
+ if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
46
+ record_ref_interaction(name, cmd.to_s, attrs)
47
+ return
48
+ end
49
+
50
+ attrs = prepare_attrs(cmd.to_s, attrs)
43
51
 
44
52
  File.open(log_path(name), "a") do |f|
45
53
  f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
@@ -53,6 +61,13 @@ module Browserctl
53
61
  lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
54
62
  ruby = build_workflow_ruby(name, lines)
55
63
  File.write(output_path, ruby) if output_path
64
+
65
+ ref_count = lines.count { |l| l[:cmd] == "_ref_interaction" }
66
+ if ref_count.positive?
67
+ warn "Warning: #{ref_count} ref-based interaction(s) were captured but cannot be replayed by ref."
68
+ warn "Search the generated workflow for 'TODO: ref-based' and replace with stable CSS selectors."
69
+ end
70
+
56
71
  ruby
57
72
  ensure
58
73
  FileUtils.rm_f(log) if log
@@ -65,6 +80,13 @@ module Browserctl
65
80
  File.join(RECORDINGS_DIR, "#{name}.jsonl")
66
81
  end
67
82
 
83
+ def record_ref_interaction(recording_name, cmd, attrs)
84
+ entry = { cmd: "_ref_interaction", action: cmd, ref: attrs[:ref], name: attrs[:name] }
85
+ File.open(log_path(recording_name), "a") do |f|
86
+ f.puts JSON.generate(entry)
87
+ end
88
+ end
89
+
68
90
  def build_workflow_ruby(name, commands)
69
91
  steps = commands.map { |c| build_step(c) }.join("\n\n")
70
92
  <<~RUBY
@@ -80,30 +102,74 @@ module Browserctl
80
102
 
81
103
  def build_step(cmd)
82
104
  label, body = step_parts(cmd)
83
- "step #{label.inspect} do\n #{body}\nend"
105
+
106
+ if body.nil?
107
+ page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
108
+ action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
109
+ return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
110
+ "replace with a stable CSS selector\n" \
111
+ "# step #{label.inspect} do\n" \
112
+ "# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
113
+ "# end"
114
+ end
115
+
116
+ url = cmd[:url].to_s
117
+ if url.include?("[REDACTED]")
118
+ "# NOTE: sensitive query params were redacted during recording\nstep #{label.inspect} do\n #{body}\nend"
119
+ else
120
+ "step #{label.inspect} do\n #{body}\nend"
121
+ end
84
122
  end
85
123
 
86
124
  def step_parts(cmd)
125
+ return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
126
+ return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
127
+
128
+ page = cmd[:name]
129
+ case cmd[:cmd]
130
+ when "open_page" then ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
131
+ when "goto" then ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
132
+ when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
133
+ when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
134
+ else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
135
+ end
136
+ end
137
+
138
+ def ref_interaction_parts(cmd)
139
+ ["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
140
+ end
141
+
142
+ def selector_parts(cmd)
87
143
  page = cmd[:name]
88
144
  case cmd[:cmd]
89
- when "open_page"
90
- ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
91
- when "goto"
92
- ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
93
145
  when "fill"
94
146
  ["fill #{cmd[:selector]} on #{page}",
95
147
  "page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
96
148
  when "click"
97
149
  ["click #{cmd[:selector]} on #{page}",
98
150
  "page(:#{page}).click(#{cmd[:selector].inspect})"]
99
- when "screenshot"
100
- ["screenshot #{page}", "page(:#{page}).screenshot"]
101
- when "evaluate"
102
- ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
103
- else
104
- ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
105
151
  end
106
152
  end
153
+
154
+ def prepare_attrs(cmd, attrs)
155
+ attrs = attrs.except(:value) if cmd == "fill"
156
+ attrs[:url] = redact_url(attrs[:url]) if %w[goto open_page].include?(cmd) && attrs[:url]
157
+ attrs
158
+ end
159
+
160
+ def redact_url(url)
161
+ uri = URI.parse(url)
162
+ return url if uri.query.nil?
163
+
164
+ uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
165
+ raw_key = ::Regexp.last_match(1)
166
+ key = URI.decode_www_form_component(raw_key)
167
+ key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
168
+ end
169
+ uri.to_s
170
+ rescue URI::InvalidURIError
171
+ url
172
+ end
107
173
  end
108
174
  end
109
175
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "workflow"
4
5
  require_relative "client"
5
6
 
@@ -39,6 +40,24 @@ module Browserctl
39
40
 
40
41
  SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
41
42
 
43
+ def self.load_params_file(path)
44
+ raise "params file not found: #{path}" unless File.exist?(path)
45
+
46
+ case File.extname(path).downcase
47
+ when ".yml", ".yaml"
48
+ require "yaml"
49
+ YAML.safe_load_file(path, symbolize_names: true)
50
+ when ".json"
51
+ JSON.parse(File.read(path), symbolize_names: true)
52
+ else
53
+ raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
54
+ end
55
+ rescue Psych::SyntaxError => e
56
+ raise "invalid YAML in #{path}: #{e.message}"
57
+ rescue JSON::ParserError => e
58
+ raise "invalid JSON in #{path}: #{e.message}"
59
+ end
60
+
42
61
  private
43
62
 
44
63
  def validate_name!(name)
@@ -48,6 +67,8 @@ module Browserctl
48
67
  end
49
68
 
50
69
  def fetch_workflow(name)
70
+ return REGISTRY[name.to_s] if REGISTRY.key?(name.to_s)
71
+
51
72
  validate_name!(name)
52
73
  load_workflow_file(name)
53
74
  REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
@@ -25,10 +25,12 @@ module Browserctl
25
25
  "inspect" => :cmd_inspect,
26
26
  "cookies" => :cmd_cookies,
27
27
  "set_cookie" => :cmd_set_cookie,
28
- "clear_cookies" => :cmd_clear_cookies
28
+ "clear_cookies" => :cmd_clear_cookies,
29
+ "import_cookies" => :cmd_import_cookies
29
30
  }.freeze
30
31
 
31
- SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
32
+ SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
33
+ SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
32
34
  SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
33
35
  CLOUDFLARE_SIGNALS = [
34
36
  "cf-challenge-running",
@@ -168,8 +170,8 @@ module Browserctl
168
170
  def safe_screenshot_path(requested, page_name)
169
171
  if requested
170
172
  expanded = File.expand_path(requested)
171
- return { error: "path outside allowed directory (#{SCREENSHOT_DIR})" } \
172
- unless expanded.start_with?(SCREENSHOT_DIR)
173
+ allowed = SCREENSHOT_ROOTS.any? { |d| expanded.start_with?("#{d}/") || expanded.start_with?(d) }
174
+ return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } unless allowed
173
175
  return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
174
176
  unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
175
177
 
@@ -235,6 +237,23 @@ module Browserctl
235
237
  { ok: true }
236
238
  end
237
239
 
240
+ def cmd_import_cookies(req)
241
+ with_page(req[:name]) do |session|
242
+ req[:cookies].each do |c|
243
+ session.page.cookies.set(
244
+ name: c[:name],
245
+ value: c[:value],
246
+ domain: c[:domain],
247
+ path: c.fetch(:path, "/"),
248
+ httponly: c[:httpOnly] == true,
249
+ secure: c[:secure] == true,
250
+ expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
251
+ )
252
+ end
253
+ { ok: true, count: req[:cookies].length }
254
+ end
255
+ end
256
+
238
257
  def cmd_inspect(req)
239
258
  session = @global_mutex.synchronize { @pages[req[:name]] }
240
259
  return { error: "no page named '#{req[:name]}'" } unless session
@@ -265,7 +284,7 @@ module Browserctl
265
284
  { ok: true, paused: false }
266
285
  end
267
286
 
268
- def cmd_ping(_req) = { ok: true, pid: Process.pid }
287
+ def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
269
288
 
270
289
  def cmd_shutdown(_req)
271
290
  Process.kill("INT", Process.pid)
@@ -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)
@@ -63,10 +64,24 @@ module Browserctl
63
64
  FileUtils.rm_f(@socket_path)
64
65
  server = UNIXServer.new(@socket_path)
65
66
  File.chmod(0o600, @socket_path)
66
- Browserctl.logger.info "listening on #{@socket_path}"
67
+ Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
67
68
  server
68
69
  end
69
70
 
71
+ def guard_already_running!
72
+ return unless File.exist?(@pid_path)
73
+
74
+ pid = File.read(@pid_path).strip.to_i
75
+ return unless pid.positive?
76
+
77
+ Process.kill(0, pid)
78
+ abort "browserd already running (PID #{pid}). Use 'browserctl shutdown' first."
79
+ rescue Errno::ESRCH
80
+ # Dead process — stale PID file, safe to continue
81
+ rescue Errno::EPERM
82
+ abort "browserd (PID #{pid}) is running as a different user. Remove #{@pid_path} manually if stale."
83
+ end
84
+
70
85
  def serve(server)
71
86
  loop do
72
87
  client = server.accept
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -16,6 +16,15 @@ module Browserctl
16
16
  def initialize(params, client)
17
17
  @params = params
18
18
  @client = client
19
+ @_store = {}
20
+ end
21
+
22
+ def store(key, value)
23
+ @_store[key] = value
24
+ end
25
+
26
+ def fetch(key)
27
+ @_store.fetch(key) { raise KeyError, "no value stored for key #{key.inspect}" }
19
28
  end
20
29
 
21
30
  def method_missing(name, *args)
@@ -32,6 +41,20 @@ module Browserctl
32
41
  PageProxy.new(name.to_s, @client)
33
42
  end
34
43
 
44
+ def open_page(page_name, url: nil)
45
+ res = @client.open_page(page_name.to_s, url: url)
46
+ raise WorkflowError, res[:error] if res[:error]
47
+
48
+ res
49
+ end
50
+
51
+ def close_page(page_name)
52
+ res = @client.close_page(page_name.to_s)
53
+ raise WorkflowError, res[:error] if res[:error]
54
+
55
+ res
56
+ end
57
+
35
58
  def invoke(workflow_name, **override_params)
36
59
  name = workflow_name.to_s
37
60
  guard_circular!(name)
@@ -77,6 +100,7 @@ module Browserctl
77
100
  def click(sel) = unwrap @client.click(@name, sel)
78
101
  def snapshot(**) = unwrap @client.snapshot(@name, **)
79
102
  def screenshot(**) = unwrap @client.screenshot(@name, **)
103
+ def watch(sel, timeout: 30) = unwrap @client.watch(@name, sel, timeout: timeout)
80
104
  def wait_for(sel, timeout: 10) = unwrap @client.wait_for(@name, sel, timeout: timeout)
81
105
  def url = @client.url(@name)[:url]
82
106
  def evaluate(expr) = @client.evaluate(@name, expr)[:result]