browserctl 0.4.0 → 0.6.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +97 -55
  4. data/bin/browserctl +117 -108
  5. data/bin/browserd +9 -3
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +6 -6
  8. data/examples/smoke/params_file.rb +3 -2
  9. data/examples/smoke/store_fetch.rb +5 -5
  10. data/examples/test_automation_practices/checkboxes.rb +39 -0
  11. data/examples/test_automation_practices/dynamic_elements.rb +40 -0
  12. data/examples/test_automation_practices/key_press.rb +41 -0
  13. data/examples/test_automation_practices/login.rb +34 -0
  14. data/examples/test_automation_practices/login_negative.rb +28 -0
  15. data/examples/test_automation_practices/notifications.rb +57 -0
  16. data/examples/the_internet/add_remove_elements.rb +1 -1
  17. data/examples/the_internet/checkboxes.rb +1 -1
  18. data/examples/the_internet/dropdown.rb +1 -1
  19. data/examples/the_internet/dynamic_loading.rb +2 -2
  20. data/examples/the_internet/login.rb +1 -1
  21. data/lib/browserctl/client.rb +112 -28
  22. data/lib/browserctl/commands/cookie.rb +59 -0
  23. data/lib/browserctl/commands/daemon.rb +77 -0
  24. data/lib/browserctl/commands/page.rb +47 -0
  25. data/lib/browserctl/commands/record.rb +1 -1
  26. data/lib/browserctl/commands/screenshot.rb +2 -2
  27. data/lib/browserctl/commands/session.rb +69 -0
  28. data/lib/browserctl/commands/snapshot.rb +5 -5
  29. data/lib/browserctl/commands/storage.rb +67 -0
  30. data/lib/browserctl/commands/workflow.rb +64 -0
  31. data/lib/browserctl/constants.rb +20 -1
  32. data/lib/browserctl/detectors.rb +23 -0
  33. data/lib/browserctl/errors.rb +25 -0
  34. data/lib/browserctl/logger.rb +4 -4
  35. data/lib/browserctl/policy.rb +36 -0
  36. data/lib/browserctl/recording.rb +4 -4
  37. data/lib/browserctl/runner.rb +4 -4
  38. data/lib/browserctl/server/command_dispatcher.rb +49 -258
  39. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  40. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  41. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  42. data/lib/browserctl/server/handlers/hitl.rb +31 -0
  43. data/lib/browserctl/server/handlers/navigation.rb +94 -0
  44. data/lib/browserctl/server/handlers/observation.rb +87 -0
  45. data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
  46. data/lib/browserctl/server/handlers/session.rb +93 -0
  47. data/lib/browserctl/server/handlers/storage.rb +109 -0
  48. data/lib/browserctl/server.rb +4 -3
  49. data/lib/browserctl/session.rb +79 -0
  50. data/lib/browserctl/version.rb +1 -1
  51. data/lib/browserctl/workflow.rb +58 -17
  52. data/lib/browserctl.rb +12 -2
  53. metadata +43 -11
  54. data/lib/browserctl/commands/export_cookies.rb +0 -18
  55. data/lib/browserctl/commands/import_cookies.rb +0 -23
  56. data/lib/browserctl/commands/inspect.rb +0 -21
  57. data/lib/browserctl/commands/open_page.rb +0 -21
  58. data/lib/browserctl/commands/pause.rb +0 -22
  59. data/lib/browserctl/commands/status.rb +0 -30
  60. data/lib/browserctl/commands/watch.rb +0 -27
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optimist"
5
+ require_relative "cli_output"
6
+
7
+ module Browserctl
8
+ module Commands
9
+ module Daemon
10
+ extend CliOutput
11
+
12
+ USAGE = "Usage: browserctl daemon <ping|status|start|stop|list> [args]"
13
+
14
+ def self.run(client, args)
15
+ sub = args.shift or abort USAGE
16
+ case sub
17
+ when "ping" then print_result(client.ping)
18
+ when "status" then run_status(client)
19
+ when "start" then run_start(args)
20
+ when "stop" then print_result(client.shutdown)
21
+ when "list" then run_list
22
+ else abort "unknown daemon subcommand '#{sub}'\n#{USAGE}"
23
+ end
24
+ end
25
+
26
+ def self.run_status(client)
27
+ ping = client.ping
28
+ pages = client.page_list[:pages] || []
29
+ page_info = pages.map do |name|
30
+ url_res = client.url(name)
31
+ { name: name, url: url_res[:url] || url_res[:error] }
32
+ end
33
+ puts JSON.pretty_generate(
34
+ daemon: "online",
35
+ pid: ping[:pid],
36
+ protocol_version: ping[:protocol_version],
37
+ pages: page_info
38
+ )
39
+ rescue RuntimeError => e
40
+ raise unless e.message.include?("browserd is not running")
41
+
42
+ puts JSON.pretty_generate(daemon: "offline", error: e.message)
43
+ exit 1
44
+ end
45
+
46
+ def self.run_start(args)
47
+ opts = Optimist.options(args) do
48
+ opt :headed, "Run with visible browser", default: false
49
+ opt :name, "Daemon name", type: :string
50
+ end
51
+ flags = []
52
+ flags << "--headed" if opts[:headed]
53
+ flags += ["--name", opts[:name]] if opts[:name]
54
+ pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
55
+ Process.detach(pid)
56
+ puts "browserd started (pid #{pid})"
57
+ end
58
+
59
+ def self.run_list
60
+ sockets = Dir[File.join(Browserctl::BROWSERCTL_DIR, "*.sock")]
61
+ rows = sockets.map do |sock_path|
62
+ daemon_name = File.basename(sock_path, ".sock")
63
+ display_name = daemon_name == "browserd" ? "default" : daemon_name
64
+ socket_name = daemon_name == "browserd" ? nil : daemon_name
65
+ client = Browserctl::Client.new(Browserctl.socket_path(socket_name))
66
+ status = client.ping
67
+ next unless status&.dig(:ok)
68
+
69
+ { name: display_name, pid: status[:pid], pages: (client.page_list[:pages] || []).length }
70
+ rescue RuntimeError
71
+ nil
72
+ end.compact
73
+ puts({ daemons: rows }.to_json)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optimist"
4
+ require_relative "cli_output"
5
+
6
+ module Browserctl
7
+ module Commands
8
+ module Page
9
+ extend CliOutput
10
+
11
+ USAGE = "Usage: browserctl page <open|close|list|focus> [args]"
12
+
13
+ def self.run(client, args)
14
+ sub = args.shift or abort USAGE
15
+ case sub
16
+ when "open" then run_open(client, args)
17
+ when "close" then run_close(client, args)
18
+ when "list" then run_list(client)
19
+ when "focus" then run_focus(client, args)
20
+ else abort "unknown page subcommand '#{sub}'\n#{USAGE}"
21
+ end
22
+ end
23
+
24
+ def self.run_open(client, args)
25
+ opts = Optimist.options(args) do
26
+ opt :url, "URL to navigate to", type: :string, short: "-u"
27
+ end
28
+ name = args.shift or abort "usage: browserctl page open <name> [--url URL]"
29
+ print_result(client.page_open(name, url: opts[:url]))
30
+ end
31
+
32
+ def self.run_close(client, args)
33
+ name = args.shift or abort "usage: browserctl page close <name>"
34
+ print_result(client.page_close(name))
35
+ end
36
+
37
+ def self.run_list(client)
38
+ print_result(client.page_list)
39
+ end
40
+
41
+ def self.run_focus(client, args)
42
+ name = args.shift or abort "usage: browserctl page focus <name>"
43
+ print_result(client.call("page_focus", name: name))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -43,7 +43,7 @@ module Browserctl
43
43
  FileUtils.mkdir_p(File.dirname(out))
44
44
  Recording.generate_workflow(name, output_path: out)
45
45
  puts "Workflow saved: #{out}"
46
- puts "Run with: browserctl run #{name}"
46
+ puts "Run with: browserctl workflow run #{name}"
47
47
  end
48
48
 
49
49
  def run_status
@@ -10,11 +10,11 @@ module Browserctl
10
10
 
11
11
  def self.run(client, args)
12
12
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl shot <page> [--out PATH] [--full]"
13
+ banner "Usage: browserctl screenshot <page> [--out PATH] [--full]"
14
14
  opt :out, "Output file path", type: :string, short: "-o"
15
15
  opt :full, "Capture full page", default: false, short: "-f"
16
16
  end
17
- name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
17
+ name = args.shift or abort "usage: browserctl screenshot <page> [--out PATH] [--full]"
18
18
  print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
19
19
  end
20
20
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Session
8
+ extend CliOutput
9
+
10
+ USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
11
+
12
+ def self.run(client, args)
13
+ sub = args.shift or abort USAGE
14
+ case sub
15
+ when "save" then run_save(client, args)
16
+ when "load" then run_load(client, args)
17
+ when "list" then run_list(client)
18
+ when "delete" then run_delete(client, args)
19
+ when "export" then run_export(args)
20
+ when "import" then run_import(args)
21
+ else abort "unknown session subcommand '#{sub}'\n#{USAGE}"
22
+ end
23
+ end
24
+
25
+ def self.run_save(client, args)
26
+ name = args.shift or abort "usage: browserctl session save <name>"
27
+ print_result(client.session_save(name))
28
+ end
29
+
30
+ def self.run_load(client, args)
31
+ name = args.shift or abort "usage: browserctl session load <name>"
32
+ print_result(client.session_load(name))
33
+ end
34
+
35
+ def self.run_list(client)
36
+ print_result(client.session_list)
37
+ end
38
+
39
+ def self.run_delete(client, args)
40
+ name = args.shift or abort "usage: browserctl session delete <name>"
41
+ print_result(client.session_delete(name))
42
+ end
43
+
44
+ def self.run_export(args)
45
+ name = args.shift or abort "usage: browserctl session export <name> <path>"
46
+ dest = args.shift or abort "usage: browserctl session export <name> <path>"
47
+ session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
48
+ abort "session '#{name}' not found" unless Dir.exist?(session_dir)
49
+
50
+ dest = File.expand_path(dest)
51
+ pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
52
+ Process.wait(pid)
53
+ puts({ ok: true, path: dest }.to_json)
54
+ end
55
+
56
+ def self.run_import(args)
57
+ zip_path = args.shift or abort "usage: browserctl session import <path>"
58
+ zip_path = File.expand_path(zip_path)
59
+ abort "zip file not found: #{zip_path}" unless File.exist?(zip_path)
60
+
61
+ sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
62
+ FileUtils.mkdir_p(sessions_dir)
63
+ pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
64
+ Process.wait(pid)
65
+ puts({ ok: true }.to_json)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -6,15 +6,15 @@ require "optimist"
6
6
  module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
- VALID_FORMATS = %w[ai html].freeze
9
+ VALID_FORMATS = %w[elements html].freeze
10
10
 
11
11
  def self.run(client, args)
12
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"
13
+ banner "Usage: browserctl snapshot <page> [--format elements|html] [--diff]"
14
+ opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
15
15
  opt :diff, "Return only changed elements", default: false, short: "-d"
16
16
  end
17
- name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
17
+ name = args.shift or abort "usage: browserctl snapshot <page> [--format elements|html] [--diff]"
18
18
  unless VALID_FORMATS.include?(opts[:format])
19
19
  warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
20
  exit 1
@@ -31,7 +31,7 @@ module Browserctl
31
31
  warn "Error: #{res[:error]}"
32
32
  exit 1
33
33
  end
34
- puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
34
+ puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Storage
8
+ extend CliOutput
9
+
10
+ USAGE = "Usage: browserctl storage <get|set|export|import|delete> [args]"
11
+
12
+ def self.run(client, args)
13
+ sub = args.shift or abort USAGE
14
+ case sub
15
+ when "get" then run_get(client, args)
16
+ when "set" then run_set(client, args)
17
+ when "export" then run_export(client, args)
18
+ when "import" then run_import(client, args)
19
+ when "delete" then run_delete(client, args)
20
+ else abort "unknown storage subcommand '#{sub}'\n#{USAGE}"
21
+ end
22
+ end
23
+
24
+ def self.run_get(client, args)
25
+ page = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
26
+ key = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
27
+ store = extract_opt(args, "--store") || "local"
28
+ print_result(client.storage_get(page, key, store: store))
29
+ end
30
+
31
+ def self.run_set(client, args)
32
+ page = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
33
+ key = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
34
+ value = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
35
+ store = extract_opt(args, "--store") || "local"
36
+ print_result(client.storage_set(page, key, value, store: store))
37
+ end
38
+
39
+ def self.run_export(client, args)
40
+ page = args.shift or abort "usage: browserctl storage export <page> <path> [--store local|session|all]"
41
+ path = args.shift or abort "usage: browserctl storage export <page> <path> [--store local|session|all]"
42
+ store = extract_opt(args, "--store") || "all"
43
+ print_result(client.storage_export(page, path, stores: store))
44
+ end
45
+
46
+ def self.run_import(client, args)
47
+ page = args.shift or abort "usage: browserctl storage import <page> <path>"
48
+ path = args.shift or abort "usage: browserctl storage import <page> <path>"
49
+ print_result(client.storage_import(page, path))
50
+ end
51
+
52
+ def self.run_delete(client, args)
53
+ page = args.shift or abort "usage: browserctl storage delete <page> [--store local|session|all]"
54
+ store = extract_opt(args, "--store") || "all"
55
+ print_result(client.storage_delete(page, stores: store))
56
+ end
57
+
58
+ def self.extract_opt(args, flag)
59
+ idx = args.index(flag)
60
+ return nil unless idx
61
+
62
+ args.delete_at(idx)
63
+ args.delete_at(idx)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Workflow
8
+ extend CliOutput
9
+
10
+ USAGE = "Usage: browserctl workflow <run|list|describe> [args]"
11
+
12
+ def self.run(runner, args)
13
+ sub = args.shift or abort USAGE
14
+ case sub
15
+ when "run" then run_workflow(runner, args)
16
+ when "list" then run_list(runner)
17
+ when "describe" then run_describe(runner, args)
18
+ else abort "unknown workflow subcommand '#{sub}'\n#{USAGE}"
19
+ end
20
+ end
21
+
22
+ def self.run_workflow(runner, args)
23
+ name = args.shift or abort "usage: browserctl workflow run <name|file> [--params file] [--key value ...]"
24
+ if File.exist?(name)
25
+ before = Browserctl.registry_snapshot.keys
26
+ load File.expand_path(name)
27
+ name = (Browserctl.registry_snapshot.keys - before).first || File.basename(name, ".rb")
28
+ end
29
+
30
+ params_file_idx = args.index("--params")
31
+ file_params = {}
32
+ if params_file_idx
33
+ params_path = args.delete_at(params_file_idx + 1)
34
+ args.delete_at(params_file_idx)
35
+ begin
36
+ file_params = Browserctl::Runner.load_params_file(params_path)
37
+ rescue StandardError => e
38
+ abort "Error loading params file: #{e.message}"
39
+ end
40
+ end
41
+
42
+ cli_params = {}
43
+ args.each_slice(2) do |flag, val|
44
+ key = flag.sub(/\A--/, "").to_sym
45
+ cli_params[key] = val
46
+ end
47
+
48
+ params = file_params.merge(cli_params)
49
+ success = runner.run_workflow(name, **params)
50
+ exit(success ? 0 : 1)
51
+ end
52
+
53
+ def self.run_list(runner)
54
+ list = runner.list_workflows
55
+ list.each { |w| puts "#{w[:name].ljust(24)} #{w[:desc]}" }
56
+ end
57
+
58
+ def self.run_describe(runner, args)
59
+ name = args.shift or abort "usage: browserctl workflow describe <name>"
60
+ puts JSON.pretty_generate(runner.describe_workflow(name))
61
+ end
62
+ end
63
+ end
64
+ end
@@ -5,7 +5,7 @@ module Browserctl
5
5
  IDLE_TTL = 30 * 60
6
6
  # Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
7
7
  # Clients read this from `ping` to verify compatibility before sending commands.
8
- PROTOCOL_VERSION = "1"
8
+ PROTOCOL_VERSION = "2"
9
9
 
10
10
  def self.socket_path(name = nil)
11
11
  File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
@@ -19,6 +19,25 @@ module Browserctl
19
19
  File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
20
20
  end
21
21
 
22
+ # Returns nil when the default (unnamed) slot is free; otherwise returns "d1", "d2", etc.
23
+ def self.next_daemon_name
24
+ return nil unless File.exist?(socket_path)
25
+
26
+ 1.upto(99) do |i|
27
+ return "d#{i}" unless File.exist?(socket_path("d#{i}"))
28
+ end
29
+ raise "too many running daemons (limit: 99)"
30
+ end
31
+
32
+ def self.all_daemon_sockets
33
+ Dir[File.join(BROWSERCTL_DIR, "*.sock")]
34
+ end
35
+
36
+ def self.all_daemon_names
37
+ all_daemon_sockets.map { |f| File.basename(f, ".sock") }
38
+ .map { |n| n == "browserd" ? nil : n }
39
+ end
40
+
22
41
  # Backward-compatible constants
23
42
  SOCKET_PATH = socket_path
24
43
  PID_PATH = pid_path
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Detectors
5
+ CLOUDFLARE_SIGNALS = [
6
+ "cf-challenge-running",
7
+ "cf_chl_opt",
8
+ "__cf_chl_f_tk",
9
+ "Just a moment..."
10
+ ].freeze
11
+
12
+ # Returns true if the page appears to be showing a Cloudflare challenge.
13
+ # Checks both the current URL and the page body for known Cloudflare signals.
14
+ # @param page [Ferrum::Page] the browser page to inspect
15
+ # @return [Boolean]
16
+ def self.cloudflare?(page)
17
+ url = page.current_url.to_s
18
+ body = page.body.to_s
19
+ url.include?("challenge-platform") ||
20
+ CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ # Base error class for all browserctl daemon errors.
5
+ # Subclasses carry a machine-readable `code` that appears in wire responses.
6
+ # @attr_reader code [String] machine-readable error code
7
+ class Error < StandardError
8
+ def self.default_code = "error"
9
+
10
+ attr_reader :code
11
+
12
+ def initialize(msg = nil, code: self.class.default_code)
13
+ @code = code
14
+ super(msg)
15
+ end
16
+ end
17
+
18
+ class PageNotFound < Error; def self.default_code = "page_not_found" end
19
+ class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
+ class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
+ class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
+ class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
+ class TimeoutError < Error; def self.default_code = "timeout" end
24
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+ end
@@ -18,10 +18,10 @@ module Browserctl
18
18
 
19
19
  # Delegate to each logger; swallow individual write failures so a broken file
20
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
21
+ def debug(msg = nil, &) = @loggers.each { |l| l.debug(msg, &) rescue nil }
22
+ def info(msg = nil, &) = @loggers.each { |l| l.info(msg, &) rescue nil }
23
+ def warn(msg = nil, &) = @loggers.each { |l| l.warn(msg, &) rescue nil }
24
+ def error(msg = nil, &) = @loggers.each { |l| l.error(msg, &) rescue nil }
25
25
 
26
26
  def level = @loggers.first&.level
27
27
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Browserctl
6
+ module Policy
7
+ # Returns true if the URL is permitted by the domain policy.
8
+ # When BROWSERCTL_ALLOWED_DOMAINS is unset, all URLs are allowed.
9
+ # @param url [String] the URL to check
10
+ # @return [Boolean]
11
+ def self.allowed_navigation?(url)
12
+ domains = allowed_domains
13
+ return true if domains.empty?
14
+
15
+ host_matches?(URI.parse(url).host, domains)
16
+ rescue URI::InvalidURIError
17
+ false
18
+ end
19
+
20
+ def self.allowed_domains
21
+ raw = ENV.fetch("BROWSERCTL_ALLOWED_DOMAINS", nil)
22
+ return [] unless raw&.match?(/\S/)
23
+
24
+ raw.split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ def self.host_matches?(host, domains)
28
+ return false unless host
29
+
30
+ normalised = host.downcase
31
+ domains.any? { |d| normalised == d.downcase || normalised.end_with?(".#{d.downcase}") }
32
+ end
33
+
34
+ private_class_method :allowed_domains, :host_matches?
35
+ end
36
+ end
@@ -11,7 +11,7 @@ module Browserctl
11
11
  RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
12
12
  STATE_FILE = File.expand_path("~/.browserctl/active_recording")
13
13
 
14
- RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
14
+ RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
15
15
 
16
16
  SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
17
17
 
@@ -127,8 +127,8 @@ module Browserctl
127
127
 
128
128
  page = cmd[:name]
129
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})"]
130
+ when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
131
+ when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
132
132
  when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
133
133
  when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
134
134
  else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
@@ -153,7 +153,7 @@ module Browserctl
153
153
 
154
154
  def prepare_attrs(cmd, attrs)
155
155
  attrs = attrs.except(:value) if cmd == "fill"
156
- attrs[:url] = redact_url(attrs[:url]) if %w[goto open_page].include?(cmd) && attrs[:url]
156
+ attrs[:url] = redact_url(attrs[:url]) if %w[navigate page_open].include?(cmd) && attrs[:url]
157
157
  attrs
158
158
  end
159
159
 
@@ -27,7 +27,7 @@ module Browserctl
27
27
  # @return [Array<Hash>] array of `{ name:, desc: }` hashes
28
28
  def list_workflows
29
29
  load_all_workflows
30
- REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
30
+ Browserctl.registry_snapshot.map { |name, defn| { name: name, desc: defn.description } }
31
31
  end
32
32
 
33
33
  # Returns detailed information about a workflow.
@@ -67,15 +67,15 @@ module Browserctl
67
67
  end
68
68
 
69
69
  def fetch_workflow(name)
70
- return REGISTRY[name.to_s] if REGISTRY.key?(name.to_s)
70
+ return Browserctl.lookup_workflow(name.to_s) if Browserctl.lookup_workflow(name.to_s)
71
71
 
72
72
  validate_name!(name)
73
73
  load_workflow_file(name)
74
- REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
74
+ Browserctl.lookup_workflow(name.to_s) || raise("workflow '#{name}' not found")
75
75
  end
76
76
 
77
77
  def load_workflow_file(name)
78
- return if REGISTRY.key?(name.to_s)
78
+ return if Browserctl.lookup_workflow(name.to_s)
79
79
 
80
80
  path = workflow_path(name)
81
81
  load path if path