browserctl 0.5.0 → 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +27 -32
  4. data/bin/browserctl +146 -108
  5. data/bin/browserd +9 -3
  6. data/examples/cloudflare_hitl.rb +5 -5
  7. data/examples/smoke/params_file.rb +3 -2
  8. data/examples/smoke/store_fetch.rb +5 -5
  9. data/examples/test_automation_practices/advanced/ab_testing.rb +38 -0
  10. data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
  11. data/examples/test_automation_practices/advanced/file_download.rb +40 -0
  12. data/examples/test_automation_practices/advanced/iframes.rb +37 -0
  13. data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
  14. data/examples/test_automation_practices/auth/login.rb +34 -0
  15. data/examples/test_automation_practices/auth/login_negative.rb +28 -0
  16. data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
  17. data/examples/test_automation_practices/dialogs/notifications.rb +57 -0
  18. data/examples/test_automation_practices/dynamic/dynamic_elements.rb +41 -0
  19. data/examples/test_automation_practices/dynamic/tables.rb +47 -0
  20. data/examples/test_automation_practices/forms/checkboxes.rb +39 -0
  21. data/examples/test_automation_practices/forms/file_upload.rb +30 -0
  22. data/examples/test_automation_practices/forms/forms.rb +47 -0
  23. data/examples/test_automation_practices/forms/slider.rb +51 -0
  24. data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
  25. data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
  26. data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
  27. data/examples/test_automation_practices/interactions/hover.rb +30 -0
  28. data/examples/test_automation_practices/interactions/key_press.rb +38 -0
  29. data/examples/the_internet/add_remove_elements.rb +1 -1
  30. data/examples/the_internet/checkboxes.rb +1 -1
  31. data/examples/the_internet/dropdown.rb +1 -1
  32. data/examples/the_internet/dynamic_loading.rb +2 -2
  33. data/examples/the_internet/login.rb +1 -1
  34. data/lib/browserctl/client.rb +143 -28
  35. data/lib/browserctl/commands/ask.rb +20 -0
  36. data/lib/browserctl/commands/cookie.rb +59 -0
  37. data/lib/browserctl/commands/daemon.rb +77 -0
  38. data/lib/browserctl/commands/dialog.rb +33 -0
  39. data/lib/browserctl/commands/page.rb +47 -0
  40. data/lib/browserctl/commands/record.rb +1 -1
  41. data/lib/browserctl/commands/screenshot.rb +2 -2
  42. data/lib/browserctl/commands/session.rb +69 -0
  43. data/lib/browserctl/commands/snapshot.rb +2 -2
  44. data/lib/browserctl/commands/storage.rb +67 -0
  45. data/lib/browserctl/commands/workflow.rb +64 -0
  46. data/lib/browserctl/constants.rb +20 -1
  47. data/lib/browserctl/logger.rb +4 -4
  48. data/lib/browserctl/recording.rb +4 -4
  49. data/lib/browserctl/server/command_dispatcher.rb +30 -9
  50. data/lib/browserctl/server/handlers/cookies.rb +1 -1
  51. data/lib/browserctl/server/handlers/devtools.rb +1 -1
  52. data/lib/browserctl/server/handlers/hitl.rb +2 -1
  53. data/lib/browserctl/server/handlers/interaction.rb +87 -0
  54. data/lib/browserctl/server/handlers/navigation.rb +24 -2
  55. data/lib/browserctl/server/handlers/observation.rb +0 -26
  56. data/lib/browserctl/server/handlers/page_lifecycle.rb +14 -3
  57. data/lib/browserctl/server/handlers/session.rb +93 -0
  58. data/lib/browserctl/server/handlers/storage.rb +109 -0
  59. data/lib/browserctl/server.rb +2 -2
  60. data/lib/browserctl/session.rb +79 -0
  61. data/lib/browserctl/version.rb +1 -1
  62. data/lib/browserctl/workflow.rb +50 -11
  63. metadata +36 -11
  64. data/lib/browserctl/commands/export_cookies.rb +0 -18
  65. data/lib/browserctl/commands/import_cookies.rb +0 -23
  66. data/lib/browserctl/commands/inspect.rb +0 -21
  67. data/lib/browserctl/commands/open_page.rb +0 -21
  68. data/lib/browserctl/commands/pause.rb +0 -22
  69. data/lib/browserctl/commands/status.rb +0 -30
  70. data/lib/browserctl/commands/watch.rb +0 -27
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module Dialog
8
+ extend CliOutput
9
+
10
+ USAGE = "Usage: browserctl dialog <accept|dismiss> <page> [text]"
11
+
12
+ def self.run(client, args)
13
+ sub = args.shift or abort USAGE
14
+ case sub
15
+ when "accept" then run_accept(client, args)
16
+ when "dismiss" then run_dismiss(client, args)
17
+ else abort "unknown dialog subcommand '#{sub}'\n#{USAGE}"
18
+ end
19
+ end
20
+
21
+ def self.run_accept(client, args)
22
+ name = args.shift or abort "usage: browserctl dialog accept <page> [text]"
23
+ text = args.shift
24
+ print_result(client.dialog_accept(name, text: text))
25
+ end
26
+
27
+ def self.run_dismiss(client, args)
28
+ name = args.shift or abort "usage: browserctl dialog dismiss <page>"
29
+ print_result(client.dialog_dismiss(name))
30
+ end
31
+ end
32
+ end
33
+ 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.page_focus(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
@@ -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 snap <page> [--format elements|html] [--diff]"
13
+ banner "Usage: browserctl snapshot <page> [--format elements|html] [--diff]"
14
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 elements|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
@@ -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
@@ -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
 
@@ -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
 
@@ -9,6 +9,9 @@ require_relative "handlers/cookies"
9
9
  require_relative "handlers/hitl"
10
10
  require_relative "handlers/devtools"
11
11
  require_relative "handlers/daemon_control"
12
+ require_relative "handlers/storage"
13
+ require_relative "handlers/session"
14
+ require_relative "handlers/interaction"
12
15
  require_relative "../detectors"
13
16
  require_relative "../policy"
14
17
 
@@ -21,31 +24,49 @@ module Browserctl
21
24
  include Handlers::Hitl
22
25
  include Handlers::DevTools
23
26
  include Handlers::DaemonControl
27
+ include Handlers::Storage
28
+ include Handlers::Session
29
+ include Handlers::Interaction
24
30
 
25
31
  COMMAND_MAP = {
26
- "open_page" => :cmd_open_page,
27
- "close_page" => :cmd_close_page,
28
- "list_pages" => :cmd_list_pages,
29
- "goto" => :cmd_goto,
32
+ "page_open" => :cmd_page_open,
33
+ "page_close" => :cmd_page_close,
34
+ "page_list" => :cmd_page_list,
35
+ "page_focus" => :cmd_page_focus,
36
+ "navigate" => :cmd_navigate,
37
+ "wait" => :cmd_wait,
30
38
  "snapshot" => :cmd_snapshot,
31
39
  "evaluate" => :cmd_evaluate,
32
40
  "fill" => :cmd_fill,
33
41
  "click" => :cmd_click,
34
42
  "screenshot" => :cmd_screenshot,
35
- "wait_for" => :cmd_wait_for,
36
- "watch" => :cmd_watch,
37
43
  "url" => :cmd_url,
38
44
  "ping" => :cmd_ping,
39
45
  "shutdown" => :cmd_shutdown,
40
46
  "pause" => :cmd_pause,
41
47
  "resume" => :cmd_resume,
42
- "inspect" => :cmd_inspect,
48
+ "devtools" => :cmd_devtools,
43
49
  "cookies" => :cmd_cookies,
44
50
  "set_cookie" => :cmd_set_cookie,
45
- "clear_cookies" => :cmd_clear_cookies,
51
+ "delete_cookies" => :cmd_delete_cookies,
46
52
  "import_cookies" => :cmd_import_cookies,
47
53
  "store" => :cmd_store,
48
- "fetch" => :cmd_fetch
54
+ "fetch" => :cmd_fetch,
55
+ "storage_get" => :cmd_storage_get,
56
+ "storage_set" => :cmd_storage_set,
57
+ "storage_export" => :cmd_storage_export,
58
+ "storage_import" => :cmd_storage_import,
59
+ "storage_delete" => :cmd_storage_delete,
60
+ "press" => :cmd_press,
61
+ "hover" => :cmd_hover,
62
+ "upload" => :cmd_upload,
63
+ "select" => :cmd_select,
64
+ "dialog_accept" => :cmd_dialog_accept,
65
+ "dialog_dismiss" => :cmd_dialog_dismiss,
66
+ "session_save" => :cmd_session_save,
67
+ "session_load" => :cmd_session_load,
68
+ "session_list" => :cmd_session_list,
69
+ "session_delete" => :cmd_session_delete
49
70
  }.freeze
50
71
 
51
72
  SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
@@ -27,7 +27,7 @@ module Browserctl
27
27
  { ok: true }
28
28
  end
29
29
 
30
- def cmd_clear_cookies(req)
30
+ def cmd_delete_cookies(req)
31
31
  session = @global_mutex.synchronize { @pages[req[:name]] }
32
32
  return { error: "no page named '#{req[:name]}'" } unless session
33
33
 
@@ -6,7 +6,7 @@ module Browserctl
6
6
  module DevTools
7
7
  private
8
8
 
9
- def cmd_inspect(req)
9
+ def cmd_devtools(req)
10
10
  session = @global_mutex.synchronize { @pages[req[:name]] }
11
11
  return { error: "no page named '#{req[:name]}'" } unless session
12
12
 
@@ -11,7 +11,8 @@ module Browserctl
11
11
  return { error: "no page named '#{req[:name]}'" } unless session
12
12
 
13
13
  session.mutex.synchronize { session.pause! }
14
- { ok: true, paused: true }
14
+ Browserctl.logger.info("HITL pause: #{req[:message]}") if req[:message]
15
+ { ok: true, paused: true, message: req[:message] }
15
16
  end
16
17
 
17
18
  def cmd_resume(req)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Interaction
7
+ private
8
+
9
+ def cmd_press(req)
10
+ with_page(req[:name]) do |session|
11
+ session.page.keyboard.down(req[:key])
12
+ session.page.keyboard.up(req[:key])
13
+ { ok: true }
14
+ end
15
+ end
16
+
17
+ def cmd_hover(req)
18
+ with_page(req[:name]) do |session|
19
+ coords = session.page.evaluate(
20
+ "(function(sel) { " \
21
+ "var el = document.querySelector(sel); " \
22
+ "if (!el) return null; " \
23
+ "var r = el.getBoundingClientRect(); " \
24
+ "return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
25
+ "})(#{req[:selector].to_json})"
26
+ )
27
+ return { error: "selector not found: #{req[:selector]}" } unless coords
28
+
29
+ session.page.mouse.move(x: coords["x"], y: coords["y"])
30
+ { ok: true }
31
+ end
32
+ end
33
+
34
+ def cmd_upload(req)
35
+ path = File.expand_path(req[:path])
36
+ return { error: "file not found: #{path}" } unless File.exist?(path)
37
+
38
+ with_page(req[:name]) do |session|
39
+ el = session.page.at_css(req[:selector])
40
+ return { error: "selector not found: #{req[:selector]}" } unless el
41
+
42
+ el.select_file(path)
43
+ { ok: true }
44
+ end
45
+ end
46
+
47
+ def cmd_select(req)
48
+ with_page(req[:name]) do |session|
49
+ el = session.page.at_css(req[:selector])
50
+ return { error: "selector not found: #{req[:selector]}" } unless el
51
+
52
+ el.evaluate(
53
+ "this.value = #{req[:value].to_json}; " \
54
+ "this.dispatchEvent(new Event('change', {bubbles: true}))"
55
+ )
56
+ { ok: true }
57
+ end
58
+ end
59
+
60
+ def cmd_dialog_accept(req)
61
+ session = @global_mutex.synchronize { @pages[req[:name]] }
62
+ return { error: "no page named '#{req[:name]}'" } unless session
63
+
64
+ text = req[:text]
65
+ id = nil
66
+ id = session.page.on(:dialog) do |dialog|
67
+ session.page.off(:dialog, id)
68
+ dialog.accept(text)
69
+ end
70
+ { ok: true }
71
+ end
72
+
73
+ def cmd_dialog_dismiss(req)
74
+ session = @global_mutex.synchronize { @pages[req[:name]] }
75
+ return { error: "no page named '#{req[:name]}'" } unless session
76
+
77
+ id = nil
78
+ id = session.page.on(:dialog) do |dialog|
79
+ session.page.off(:dialog, id)
80
+ dialog.dismiss
81
+ end
82
+ { ok: true }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -6,7 +6,7 @@ module Browserctl
6
6
  module Navigation
7
7
  private
8
8
 
9
- def cmd_goto(req)
9
+ def cmd_navigate(req)
10
10
  unless Policy.allowed_navigation?(req[:url].to_s)
11
11
  return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
12
12
  end
@@ -17,6 +17,13 @@ module Browserctl
17
17
  end
18
18
  end
19
19
 
20
+ def cmd_wait(req)
21
+ with_page(req[:name]) do |session|
22
+ result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
23
+ result[:error] ? result : { ok: true, selector: req[:selector] }
24
+ end
25
+ end
26
+
20
27
  def cmd_evaluate(req)
21
28
  with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
22
29
  end
@@ -48,6 +55,7 @@ module Browserctl
48
55
  return { error: "selector not found: #{selector}" } unless el
49
56
 
50
57
  el.focus
58
+ el.evaluate("this.select()")
51
59
  el.type(value)
52
60
  { ok: true }
53
61
  end
@@ -56,7 +64,10 @@ module Browserctl
56
64
  el = page.at_css(selector)
57
65
  return { error: "selector not found: #{selector}" } unless el
58
66
 
59
- el.click
67
+ # Use the DOM native click() so JS-only event listeners fire.
68
+ # CDP mouse simulation (el.click) dispatches events at screen coordinates
69
+ # and misses handlers on elements with no form submit chain.
70
+ el.evaluate("this.click()")
60
71
  { ok: true }
61
72
  end
62
73
 
@@ -66,6 +77,17 @@ module Browserctl
66
77
 
67
78
  session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
68
79
  end
80
+
81
+ def wait_for_selector(page, selector, timeout)
82
+ deadline = Time.now + timeout
83
+ loop do
84
+ found = page.at_css(selector)
85
+ break { ok: true } if found
86
+ break { error: "wait timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
87
+
88
+ sleep 0.2
89
+ end
90
+ end
69
91
  end
70
92
  end
71
93
  end