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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +27 -32
- data/bin/browserctl +117 -108
- data/bin/browserd +9 -3
- data/examples/cloudflare_hitl.rb +5 -5
- data/examples/smoke/params_file.rb +3 -2
- data/examples/smoke/store_fetch.rb +5 -5
- data/examples/test_automation_practices/checkboxes.rb +39 -0
- data/examples/test_automation_practices/dynamic_elements.rb +40 -0
- data/examples/test_automation_practices/key_press.rb +41 -0
- data/examples/test_automation_practices/login.rb +34 -0
- data/examples/test_automation_practices/login_negative.rb +28 -0
- data/examples/test_automation_practices/notifications.rb +57 -0
- data/examples/the_internet/add_remove_elements.rb +1 -1
- data/examples/the_internet/checkboxes.rb +1 -1
- data/examples/the_internet/dropdown.rb +1 -1
- data/examples/the_internet/dynamic_loading.rb +2 -2
- data/examples/the_internet/login.rb +1 -1
- data/lib/browserctl/client.rb +101 -28
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/page.rb +47 -0
- data/lib/browserctl/commands/record.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/session.rb +69 -0
- data/lib/browserctl/commands/snapshot.rb +2 -2
- data/lib/browserctl/commands/storage.rb +67 -0
- data/lib/browserctl/commands/workflow.rb +64 -0
- data/lib/browserctl/constants.rb +20 -1
- data/lib/browserctl/logger.rb +4 -4
- data/lib/browserctl/recording.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +22 -9
- data/lib/browserctl/server/handlers/cookies.rb +1 -1
- data/lib/browserctl/server/handlers/devtools.rb +1 -1
- data/lib/browserctl/server/handlers/hitl.rb +2 -1
- data/lib/browserctl/server/handlers/navigation.rb +24 -2
- data/lib/browserctl/server/handlers/observation.rb +0 -26
- data/lib/browserctl/server/handlers/page_lifecycle.rb +10 -3
- data/lib/browserctl/server/handlers/session.rb +93 -0
- data/lib/browserctl/server/handlers/storage.rb +109 -0
- data/lib/browserctl/server.rb +2 -2
- data/lib/browserctl/session.rb +79 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +38 -11
- metadata +19 -11
- data/lib/browserctl/commands/export_cookies.rb +0 -18
- data/lib/browserctl/commands/import_cookies.rb +0 -23
- data/lib/browserctl/commands/inspect.rb +0 -21
- data/lib/browserctl/commands/open_page.rb +0 -21
- data/lib/browserctl/commands/pause.rb +0 -22
- data/lib/browserctl/commands/status.rb +0 -30
- 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
|
|
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
|
|
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
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -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 = "
|
|
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
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -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 }
|
|
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 }
|
|
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
|
|
data/lib/browserctl/recording.rb
CHANGED
|
@@ -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[
|
|
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 "
|
|
131
|
-
when "
|
|
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[
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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
|
-
"
|
|
46
|
+
"devtools" => :cmd_devtools,
|
|
43
47
|
"cookies" => :cmd_cookies,
|
|
44
48
|
"set_cookie" => :cmd_set_cookie,
|
|
45
|
-
"
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|