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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +27 -32
- data/bin/browserctl +146 -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/advanced/ab_testing.rb +38 -0
- data/examples/test_automation_practices/advanced/broken_images.rb +25 -0
- data/examples/test_automation_practices/advanced/file_download.rb +40 -0
- data/examples/test_automation_practices/advanced/iframes.rb +37 -0
- data/examples/test_automation_practices/advanced/shadow_dom.rb +35 -0
- data/examples/test_automation_practices/auth/login.rb +34 -0
- data/examples/test_automation_practices/auth/login_negative.rb +28 -0
- data/examples/test_automation_practices/dialogs/alerts.rb +45 -0
- data/examples/test_automation_practices/dialogs/notifications.rb +57 -0
- data/examples/test_automation_practices/dynamic/dynamic_elements.rb +41 -0
- data/examples/test_automation_practices/dynamic/tables.rb +47 -0
- data/examples/test_automation_practices/forms/checkboxes.rb +39 -0
- data/examples/test_automation_practices/forms/file_upload.rb +30 -0
- data/examples/test_automation_practices/forms/forms.rb +47 -0
- data/examples/test_automation_practices/forms/slider.rb +51 -0
- data/examples/test_automation_practices/interactions/context_menu.rb +54 -0
- data/examples/test_automation_practices/interactions/drag_drop.rb +41 -0
- data/examples/test_automation_practices/interactions/exit_intent.rb +47 -0
- data/examples/test_automation_practices/interactions/hover.rb +30 -0
- data/examples/test_automation_practices/interactions/key_press.rb +38 -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 +143 -28
- data/lib/browserctl/commands/ask.rb +20 -0
- data/lib/browserctl/commands/cookie.rb +59 -0
- data/lib/browserctl/commands/daemon.rb +77 -0
- data/lib/browserctl/commands/dialog.rb +33 -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 +30 -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/interaction.rb +87 -0
- 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 +14 -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 +50 -11
- metadata +36 -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,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
|
|
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
|
|
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
|
|
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,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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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
|
-
"
|
|
48
|
+
"devtools" => :cmd_devtools,
|
|
43
49
|
"cookies" => :cmd_cookies,
|
|
44
50
|
"set_cookie" => :cmd_set_cookie,
|
|
45
|
-
"
|
|
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
|
|
@@ -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)
|
|
@@ -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
|
|
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
|