browserctl 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +97 -55
- data/bin/browserctl +117 -108
- data/bin/browserd +9 -3
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +6 -6
- 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 +112 -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 +5 -5
- 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/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/logger.rb +4 -4
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/recording.rb +4 -4
- data/lib/browserctl/runner.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +49 -258
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +31 -0
- data/lib/browserctl/server/handlers/navigation.rb +94 -0
- data/lib/browserctl/server/handlers/observation.rb +87 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +36 -0
- data/lib/browserctl/server/handlers/session.rb +93 -0
- data/lib/browserctl/server/handlers/storage.rb +109 -0
- data/lib/browserctl/server.rb +4 -3
- data/lib/browserctl/session.rb +79 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +58 -17
- data/lib/browserctl.rb +12 -2
- metadata +43 -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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optimist"
|
|
5
|
+
require_relative "cli_output"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module Commands
|
|
9
|
+
module Daemon
|
|
10
|
+
extend CliOutput
|
|
11
|
+
|
|
12
|
+
USAGE = "Usage: browserctl daemon <ping|status|start|stop|list> [args]"
|
|
13
|
+
|
|
14
|
+
def self.run(client, args)
|
|
15
|
+
sub = args.shift or abort USAGE
|
|
16
|
+
case sub
|
|
17
|
+
when "ping" then print_result(client.ping)
|
|
18
|
+
when "status" then run_status(client)
|
|
19
|
+
when "start" then run_start(args)
|
|
20
|
+
when "stop" then print_result(client.shutdown)
|
|
21
|
+
when "list" then run_list
|
|
22
|
+
else abort "unknown daemon subcommand '#{sub}'\n#{USAGE}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.run_status(client)
|
|
27
|
+
ping = client.ping
|
|
28
|
+
pages = client.page_list[:pages] || []
|
|
29
|
+
page_info = pages.map do |name|
|
|
30
|
+
url_res = client.url(name)
|
|
31
|
+
{ name: name, url: url_res[:url] || url_res[:error] }
|
|
32
|
+
end
|
|
33
|
+
puts JSON.pretty_generate(
|
|
34
|
+
daemon: "online",
|
|
35
|
+
pid: ping[:pid],
|
|
36
|
+
protocol_version: ping[:protocol_version],
|
|
37
|
+
pages: page_info
|
|
38
|
+
)
|
|
39
|
+
rescue RuntimeError => e
|
|
40
|
+
raise unless e.message.include?("browserd is not running")
|
|
41
|
+
|
|
42
|
+
puts JSON.pretty_generate(daemon: "offline", error: e.message)
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_start(args)
|
|
47
|
+
opts = Optimist.options(args) do
|
|
48
|
+
opt :headed, "Run with visible browser", default: false
|
|
49
|
+
opt :name, "Daemon name", type: :string
|
|
50
|
+
end
|
|
51
|
+
flags = []
|
|
52
|
+
flags << "--headed" if opts[:headed]
|
|
53
|
+
flags += ["--name", opts[:name]] if opts[:name]
|
|
54
|
+
pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
|
|
55
|
+
Process.detach(pid)
|
|
56
|
+
puts "browserd started (pid #{pid})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.run_list
|
|
60
|
+
sockets = Dir[File.join(Browserctl::BROWSERCTL_DIR, "*.sock")]
|
|
61
|
+
rows = sockets.map do |sock_path|
|
|
62
|
+
daemon_name = File.basename(sock_path, ".sock")
|
|
63
|
+
display_name = daemon_name == "browserd" ? "default" : daemon_name
|
|
64
|
+
socket_name = daemon_name == "browserd" ? nil : daemon_name
|
|
65
|
+
client = Browserctl::Client.new(Browserctl.socket_path(socket_name))
|
|
66
|
+
status = client.ping
|
|
67
|
+
next unless status&.dig(:ok)
|
|
68
|
+
|
|
69
|
+
{ name: display_name, pid: status[:pid], pages: (client.page_list[:pages] || []).length }
|
|
70
|
+
rescue RuntimeError
|
|
71
|
+
nil
|
|
72
|
+
end.compact
|
|
73
|
+
puts({ daemons: rows }.to_json)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optimist"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Commands
|
|
8
|
+
module Page
|
|
9
|
+
extend CliOutput
|
|
10
|
+
|
|
11
|
+
USAGE = "Usage: browserctl page <open|close|list|focus> [args]"
|
|
12
|
+
|
|
13
|
+
def self.run(client, args)
|
|
14
|
+
sub = args.shift or abort USAGE
|
|
15
|
+
case sub
|
|
16
|
+
when "open" then run_open(client, args)
|
|
17
|
+
when "close" then run_close(client, args)
|
|
18
|
+
when "list" then run_list(client)
|
|
19
|
+
when "focus" then run_focus(client, args)
|
|
20
|
+
else abort "unknown page subcommand '#{sub}'\n#{USAGE}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run_open(client, args)
|
|
25
|
+
opts = Optimist.options(args) do
|
|
26
|
+
opt :url, "URL to navigate to", type: :string, short: "-u"
|
|
27
|
+
end
|
|
28
|
+
name = args.shift or abort "usage: browserctl page open <name> [--url URL]"
|
|
29
|
+
print_result(client.page_open(name, url: opts[:url]))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.run_close(client, args)
|
|
33
|
+
name = args.shift or abort "usage: browserctl page close <name>"
|
|
34
|
+
print_result(client.page_close(name))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.run_list(client)
|
|
38
|
+
print_result(client.page_list)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.run_focus(client, args)
|
|
42
|
+
name = args.shift or abort "usage: browserctl page focus <name>"
|
|
43
|
+
print_result(client.call("page_focus", name: name))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -43,7 +43,7 @@ module Browserctl
|
|
|
43
43
|
FileUtils.mkdir_p(File.dirname(out))
|
|
44
44
|
Recording.generate_workflow(name, output_path: out)
|
|
45
45
|
puts "Workflow saved: #{out}"
|
|
46
|
-
puts "Run with: browserctl run #{name}"
|
|
46
|
+
puts "Run with: browserctl workflow run #{name}"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def run_status
|
|
@@ -10,11 +10,11 @@ module Browserctl
|
|
|
10
10
|
|
|
11
11
|
def self.run(client, args)
|
|
12
12
|
opts = Optimist.options(args) do
|
|
13
|
-
banner "Usage: browserctl
|
|
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
|
|
@@ -6,15 +6,15 @@ require "optimist"
|
|
|
6
6
|
module Browserctl
|
|
7
7
|
module Commands
|
|
8
8
|
class Snapshot
|
|
9
|
-
VALID_FORMATS = %w[
|
|
9
|
+
VALID_FORMATS = %w[elements html].freeze
|
|
10
10
|
|
|
11
11
|
def self.run(client, args)
|
|
12
12
|
opts = Optimist.options(args) do
|
|
13
|
-
banner "Usage: browserctl
|
|
14
|
-
opt :format, "Output format:
|
|
13
|
+
banner "Usage: browserctl snapshot <page> [--format elements|html] [--diff]"
|
|
14
|
+
opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
|
|
15
15
|
opt :diff, "Return only changed elements", default: false, short: "-d"
|
|
16
16
|
end
|
|
17
|
-
name = args.shift or abort "usage: browserctl
|
|
17
|
+
name = args.shift or abort "usage: browserctl snapshot <page> [--format elements|html] [--diff]"
|
|
18
18
|
unless VALID_FORMATS.include?(opts[:format])
|
|
19
19
|
warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
|
|
20
20
|
exit 1
|
|
@@ -31,7 +31,7 @@ module Browserctl
|
|
|
31
31
|
warn "Error: #{res[:error]}"
|
|
32
32
|
exit 1
|
|
33
33
|
end
|
|
34
|
-
puts(format == "
|
|
34
|
+
puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Storage
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
USAGE = "Usage: browserctl storage <get|set|export|import|delete> [args]"
|
|
11
|
+
|
|
12
|
+
def self.run(client, args)
|
|
13
|
+
sub = args.shift or abort USAGE
|
|
14
|
+
case sub
|
|
15
|
+
when "get" then run_get(client, args)
|
|
16
|
+
when "set" then run_set(client, args)
|
|
17
|
+
when "export" then run_export(client, args)
|
|
18
|
+
when "import" then run_import(client, args)
|
|
19
|
+
when "delete" then run_delete(client, args)
|
|
20
|
+
else abort "unknown storage subcommand '#{sub}'\n#{USAGE}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run_get(client, args)
|
|
25
|
+
page = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
|
|
26
|
+
key = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
|
|
27
|
+
store = extract_opt(args, "--store") || "local"
|
|
28
|
+
print_result(client.storage_get(page, key, store: store))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.run_set(client, args)
|
|
32
|
+
page = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
|
|
33
|
+
key = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
|
|
34
|
+
value = args.shift or abort "usage: browserctl storage set <page> <key> <value> [--store local|session]"
|
|
35
|
+
store = extract_opt(args, "--store") || "local"
|
|
36
|
+
print_result(client.storage_set(page, key, value, store: store))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.run_export(client, args)
|
|
40
|
+
page = args.shift or abort "usage: browserctl storage export <page> <path> [--store local|session|all]"
|
|
41
|
+
path = args.shift or abort "usage: browserctl storage export <page> <path> [--store local|session|all]"
|
|
42
|
+
store = extract_opt(args, "--store") || "all"
|
|
43
|
+
print_result(client.storage_export(page, path, stores: store))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_import(client, args)
|
|
47
|
+
page = args.shift or abort "usage: browserctl storage import <page> <path>"
|
|
48
|
+
path = args.shift or abort "usage: browserctl storage import <page> <path>"
|
|
49
|
+
print_result(client.storage_import(page, path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.run_delete(client, args)
|
|
53
|
+
page = args.shift or abort "usage: browserctl storage delete <page> [--store local|session|all]"
|
|
54
|
+
store = extract_opt(args, "--store") || "all"
|
|
55
|
+
print_result(client.storage_delete(page, stores: store))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.extract_opt(args, flag)
|
|
59
|
+
idx = args.index(flag)
|
|
60
|
+
return nil unless idx
|
|
61
|
+
|
|
62
|
+
args.delete_at(idx)
|
|
63
|
+
args.delete_at(idx)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Workflow
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
USAGE = "Usage: browserctl workflow <run|list|describe> [args]"
|
|
11
|
+
|
|
12
|
+
def self.run(runner, args)
|
|
13
|
+
sub = args.shift or abort USAGE
|
|
14
|
+
case sub
|
|
15
|
+
when "run" then run_workflow(runner, args)
|
|
16
|
+
when "list" then run_list(runner)
|
|
17
|
+
when "describe" then run_describe(runner, args)
|
|
18
|
+
else abort "unknown workflow subcommand '#{sub}'\n#{USAGE}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.run_workflow(runner, args)
|
|
23
|
+
name = args.shift or abort "usage: browserctl workflow run <name|file> [--params file] [--key value ...]"
|
|
24
|
+
if File.exist?(name)
|
|
25
|
+
before = Browserctl.registry_snapshot.keys
|
|
26
|
+
load File.expand_path(name)
|
|
27
|
+
name = (Browserctl.registry_snapshot.keys - before).first || File.basename(name, ".rb")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
params_file_idx = args.index("--params")
|
|
31
|
+
file_params = {}
|
|
32
|
+
if params_file_idx
|
|
33
|
+
params_path = args.delete_at(params_file_idx + 1)
|
|
34
|
+
args.delete_at(params_file_idx)
|
|
35
|
+
begin
|
|
36
|
+
file_params = Browserctl::Runner.load_params_file(params_path)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
abort "Error loading params file: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
cli_params = {}
|
|
43
|
+
args.each_slice(2) do |flag, val|
|
|
44
|
+
key = flag.sub(/\A--/, "").to_sym
|
|
45
|
+
cli_params[key] = val
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
params = file_params.merge(cli_params)
|
|
49
|
+
success = runner.run_workflow(name, **params)
|
|
50
|
+
exit(success ? 0 : 1)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.run_list(runner)
|
|
54
|
+
list = runner.list_workflows
|
|
55
|
+
list.each { |w| puts "#{w[:name].ljust(24)} #{w[:desc]}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.run_describe(runner, args)
|
|
59
|
+
name = args.shift or abort "usage: browserctl workflow describe <name>"
|
|
60
|
+
puts JSON.pretty_generate(runner.describe_workflow(name))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Detectors
|
|
5
|
+
CLOUDFLARE_SIGNALS = [
|
|
6
|
+
"cf-challenge-running",
|
|
7
|
+
"cf_chl_opt",
|
|
8
|
+
"__cf_chl_f_tk",
|
|
9
|
+
"Just a moment..."
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# Returns true if the page appears to be showing a Cloudflare challenge.
|
|
13
|
+
# Checks both the current URL and the page body for known Cloudflare signals.
|
|
14
|
+
# @param page [Ferrum::Page] the browser page to inspect
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def self.cloudflare?(page)
|
|
17
|
+
url = page.current_url.to_s
|
|
18
|
+
body = page.body.to_s
|
|
19
|
+
url.include?("challenge-platform") ||
|
|
20
|
+
CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
# Base error class for all browserctl daemon errors.
|
|
5
|
+
# Subclasses carry a machine-readable `code` that appears in wire responses.
|
|
6
|
+
# @attr_reader code [String] machine-readable error code
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
def self.default_code = "error"
|
|
9
|
+
|
|
10
|
+
attr_reader :code
|
|
11
|
+
|
|
12
|
+
def initialize(msg = nil, code: self.class.default_code)
|
|
13
|
+
@code = code
|
|
14
|
+
super(msg)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class PageNotFound < Error; def self.default_code = "page_not_found" end
|
|
19
|
+
class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
|
|
20
|
+
class RefNotFound < Error; def self.default_code = "ref_not_found" end
|
|
21
|
+
class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
|
|
22
|
+
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
23
|
+
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
|
+
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
|
+
end
|
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
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Policy
|
|
7
|
+
# Returns true if the URL is permitted by the domain policy.
|
|
8
|
+
# When BROWSERCTL_ALLOWED_DOMAINS is unset, all URLs are allowed.
|
|
9
|
+
# @param url [String] the URL to check
|
|
10
|
+
# @return [Boolean]
|
|
11
|
+
def self.allowed_navigation?(url)
|
|
12
|
+
domains = allowed_domains
|
|
13
|
+
return true if domains.empty?
|
|
14
|
+
|
|
15
|
+
host_matches?(URI.parse(url).host, domains)
|
|
16
|
+
rescue URI::InvalidURIError
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.allowed_domains
|
|
21
|
+
raw = ENV.fetch("BROWSERCTL_ALLOWED_DOMAINS", nil)
|
|
22
|
+
return [] unless raw&.match?(/\S/)
|
|
23
|
+
|
|
24
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.host_matches?(host, domains)
|
|
28
|
+
return false unless host
|
|
29
|
+
|
|
30
|
+
normalised = host.downcase
|
|
31
|
+
domains.any? { |d| normalised == d.downcase || normalised.end_with?(".#{d.downcase}") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :allowed_domains, :host_matches?
|
|
35
|
+
end
|
|
36
|
+
end
|
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
|
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Browserctl
|
|
|
27
27
|
# @return [Array<Hash>] array of `{ name:, desc: }` hashes
|
|
28
28
|
def list_workflows
|
|
29
29
|
load_all_workflows
|
|
30
|
-
|
|
30
|
+
Browserctl.registry_snapshot.map { |name, defn| { name: name, desc: defn.description } }
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# Returns detailed information about a workflow.
|
|
@@ -67,15 +67,15 @@ module Browserctl
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def fetch_workflow(name)
|
|
70
|
-
return
|
|
70
|
+
return Browserctl.lookup_workflow(name.to_s) if Browserctl.lookup_workflow(name.to_s)
|
|
71
71
|
|
|
72
72
|
validate_name!(name)
|
|
73
73
|
load_workflow_file(name)
|
|
74
|
-
|
|
74
|
+
Browserctl.lookup_workflow(name.to_s) || raise("workflow '#{name}' not found")
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def load_workflow_file(name)
|
|
78
|
-
return if
|
|
78
|
+
return if Browserctl.lookup_workflow(name.to_s)
|
|
79
79
|
|
|
80
80
|
path = workflow_path(name)
|
|
81
81
|
load path if path
|