browserctl 0.5.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +27 -32
  4. data/bin/browserctl +117 -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/checkboxes.rb +39 -0
  10. data/examples/test_automation_practices/dynamic_elements.rb +40 -0
  11. data/examples/test_automation_practices/key_press.rb +41 -0
  12. data/examples/test_automation_practices/login.rb +34 -0
  13. data/examples/test_automation_practices/login_negative.rb +28 -0
  14. data/examples/test_automation_practices/notifications.rb +57 -0
  15. data/examples/the_internet/add_remove_elements.rb +1 -1
  16. data/examples/the_internet/checkboxes.rb +1 -1
  17. data/examples/the_internet/dropdown.rb +1 -1
  18. data/examples/the_internet/dynamic_loading.rb +2 -2
  19. data/examples/the_internet/login.rb +1 -1
  20. data/lib/browserctl/client.rb +101 -28
  21. data/lib/browserctl/commands/cookie.rb +59 -0
  22. data/lib/browserctl/commands/daemon.rb +77 -0
  23. data/lib/browserctl/commands/page.rb +47 -0
  24. data/lib/browserctl/commands/record.rb +1 -1
  25. data/lib/browserctl/commands/screenshot.rb +2 -2
  26. data/lib/browserctl/commands/session.rb +69 -0
  27. data/lib/browserctl/commands/snapshot.rb +2 -2
  28. data/lib/browserctl/commands/storage.rb +67 -0
  29. data/lib/browserctl/commands/workflow.rb +64 -0
  30. data/lib/browserctl/constants.rb +20 -1
  31. data/lib/browserctl/logger.rb +4 -4
  32. data/lib/browserctl/recording.rb +4 -4
  33. data/lib/browserctl/server/command_dispatcher.rb +22 -9
  34. data/lib/browserctl/server/handlers/cookies.rb +1 -1
  35. data/lib/browserctl/server/handlers/devtools.rb +1 -1
  36. data/lib/browserctl/server/handlers/hitl.rb +2 -1
  37. data/lib/browserctl/server/handlers/navigation.rb +24 -2
  38. data/lib/browserctl/server/handlers/observation.rb +0 -26
  39. data/lib/browserctl/server/handlers/page_lifecycle.rb +10 -3
  40. data/lib/browserctl/server/handlers/session.rb +93 -0
  41. data/lib/browserctl/server/handlers/storage.rb +109 -0
  42. data/lib/browserctl/server.rb +2 -2
  43. data/lib/browserctl/session.rb +79 -0
  44. data/lib/browserctl/version.rb +1 -1
  45. data/lib/browserctl/workflow.rb +38 -11
  46. metadata +19 -11
  47. data/lib/browserctl/commands/export_cookies.rb +0 -18
  48. data/lib/browserctl/commands/import_cookies.rb +0 -23
  49. data/lib/browserctl/commands/inspect.rb +0 -21
  50. data/lib/browserctl/commands/open_page.rb +0 -21
  51. data/lib/browserctl/commands/pause.rb +0 -22
  52. data/lib/browserctl/commands/status.rb +0 -30
  53. data/lib/browserctl/commands/watch.rb +0 -27
@@ -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,8 @@ 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"
12
14
  require_relative "../detectors"
13
15
  require_relative "../policy"
14
16
 
@@ -21,31 +23,42 @@ module Browserctl
21
23
  include Handlers::Hitl
22
24
  include Handlers::DevTools
23
25
  include Handlers::DaemonControl
26
+ include Handlers::Storage
27
+ include Handlers::Session
24
28
 
25
29
  COMMAND_MAP = {
26
- "open_page" => :cmd_open_page,
27
- "close_page" => :cmd_close_page,
28
- "list_pages" => :cmd_list_pages,
29
- "goto" => :cmd_goto,
30
+ "page_open" => :cmd_page_open,
31
+ "page_close" => :cmd_page_close,
32
+ "page_list" => :cmd_page_list,
33
+ "page_focus" => :cmd_page_focus,
34
+ "navigate" => :cmd_navigate,
35
+ "wait" => :cmd_wait,
30
36
  "snapshot" => :cmd_snapshot,
31
37
  "evaluate" => :cmd_evaluate,
32
38
  "fill" => :cmd_fill,
33
39
  "click" => :cmd_click,
34
40
  "screenshot" => :cmd_screenshot,
35
- "wait_for" => :cmd_wait_for,
36
- "watch" => :cmd_watch,
37
41
  "url" => :cmd_url,
38
42
  "ping" => :cmd_ping,
39
43
  "shutdown" => :cmd_shutdown,
40
44
  "pause" => :cmd_pause,
41
45
  "resume" => :cmd_resume,
42
- "inspect" => :cmd_inspect,
46
+ "devtools" => :cmd_devtools,
43
47
  "cookies" => :cmd_cookies,
44
48
  "set_cookie" => :cmd_set_cookie,
45
- "clear_cookies" => :cmd_clear_cookies,
49
+ "delete_cookies" => :cmd_delete_cookies,
46
50
  "import_cookies" => :cmd_import_cookies,
47
51
  "store" => :cmd_store,
48
- "fetch" => :cmd_fetch
52
+ "fetch" => :cmd_fetch,
53
+ "storage_get" => :cmd_storage_get,
54
+ "storage_set" => :cmd_storage_set,
55
+ "storage_export" => :cmd_storage_export,
56
+ "storage_import" => :cmd_storage_import,
57
+ "storage_delete" => :cmd_storage_delete,
58
+ "session_save" => :cmd_session_save,
59
+ "session_load" => :cmd_session_load,
60
+ "session_list" => :cmd_session_list,
61
+ "session_delete" => :cmd_session_delete
49
62
  }.freeze
50
63
 
51
64
  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)
@@ -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
@@ -81,32 +81,6 @@ module Browserctl
81
81
  name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
82
82
  File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
83
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
84
  end
111
85
  end
112
86
  end
@@ -6,7 +6,7 @@ module Browserctl
6
6
  module PageLifecycle
7
7
  private
8
8
 
9
- def cmd_open_page(req)
9
+ def cmd_page_open(req)
10
10
  session = @global_mutex.synchronize do
11
11
  @pages[req[:name]] ||= PageSession.new(@browser.create_page)
12
12
  end
@@ -14,15 +14,22 @@ module Browserctl
14
14
  { ok: true, name: req[:name] }
15
15
  end
16
16
 
17
- def cmd_close_page(req)
17
+ def cmd_page_close(req)
18
18
  session = @global_mutex.synchronize { @pages.delete(req[:name]) }
19
19
  session&.page&.close
20
20
  { ok: true }
21
21
  end
22
22
 
23
- def cmd_list_pages(_req)
23
+ def cmd_page_list(_req)
24
24
  { pages: @global_mutex.synchronize { @pages.keys } }
25
25
  end
26
+
27
+ def cmd_page_focus(req)
28
+ with_page(req[:name]) do |session|
29
+ session.page.activate
30
+ { ok: true }
31
+ end
32
+ end
26
33
  end
27
34
  end
28
35
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../session"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ module Session
9
+ private
10
+
11
+ def cmd_session_save(req)
12
+ first_session = @global_mutex.synchronize { @pages.values.first }
13
+ return { error: "no open pages — open a page before saving a session" } unless first_session
14
+
15
+ cookies = first_session.page.cookies.all.values.map(&:to_h)
16
+
17
+ pages_meta = {}
18
+ local_storage = {}
19
+ @global_mutex.synchronize { @pages.dup }.each do |page_name, session|
20
+ session.mutex.synchronize do
21
+ origin = session.page.evaluate("location.origin")
22
+ local_str = session.page.evaluate("JSON.stringify({...localStorage})")
23
+ pages_meta[page_name] = { url: session.page.current_url, title: session.page.title }
24
+ local_storage[origin] = JSON.parse(local_str)
25
+ end
26
+ end
27
+
28
+ now = Time.now.iso8601
29
+ Browserctl::Session.save(
30
+ req[:session_name],
31
+ metadata: { version: 1, name: req[:session_name],
32
+ created_at: now, updated_at: now, pages: pages_meta },
33
+ cookies: cookies,
34
+ local_storage: local_storage,
35
+ session_storage: {}
36
+ )
37
+ { ok: true, path: Browserctl::Session.path(req[:session_name]),
38
+ pages: pages_meta.length, cookies: cookies.length }
39
+ end
40
+
41
+ def cmd_session_load(req)
42
+ data = Browserctl::Session.load(req[:session_name])
43
+
44
+ data[:metadata][:pages].each do |page_name, page_data|
45
+ existing = @global_mutex.synchronize { @pages[page_name.to_s] }
46
+ if existing
47
+ existing.page.go_to(page_data[:url])
48
+ else
49
+ new_page = @browser.create_page
50
+ new_page.go_to(page_data[:url])
51
+ @global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
52
+ end
53
+ end
54
+
55
+ seed_session = @global_mutex.synchronize { @pages.values.first }
56
+ cookie_count = data[:cookies].length
57
+ data[:cookies].each { |c| seed_session.page.cookies.set(**c.slice(:name, :value, :domain, :path)) }
58
+
59
+ ls_key_count = 0
60
+ data[:local_storage].each do |origin, keys|
61
+ next if keys.empty?
62
+
63
+ tmp_page = @browser.create_page
64
+ begin
65
+ tmp_page.go_to(origin)
66
+ keys.each do |k, v|
67
+ tmp_page.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
68
+ ls_key_count += 1
69
+ end
70
+ ensure
71
+ tmp_page.close
72
+ end
73
+ end
74
+
75
+ { ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
76
+ local_storage_keys: ls_key_count }
77
+ rescue RuntimeError => e
78
+ { error: e.message }
79
+ end
80
+
81
+ def cmd_session_list(_req)
82
+ sessions = Browserctl::Session.all
83
+ { ok: true, sessions: sessions }
84
+ end
85
+
86
+ def cmd_session_delete(req)
87
+ Browserctl::Session.delete(req[:session_name])
88
+ { ok: true }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end