browserctl 0.3.1 → 0.4.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 +26 -0
- data/README.md +56 -197
- data/bin/browserctl +33 -11
- data/bin/browserd +7 -1
- data/examples/smoke/params_file.rb +35 -0
- data/examples/smoke/store_fetch.rb +39 -0
- data/examples/the_internet/add_remove_elements.rb +3 -3
- data/examples/the_internet/checkboxes.rb +3 -3
- data/examples/the_internet/dropdown.rb +3 -3
- data/examples/the_internet/dynamic_loading.rb +3 -3
- data/examples/the_internet/login.rb +5 -5
- data/lib/browserctl/client.rb +25 -0
- data/lib/browserctl/commands/export_cookies.rb +18 -0
- data/lib/browserctl/commands/import_cookies.rb +23 -0
- data/lib/browserctl/commands/init.rb +11 -0
- data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
- data/lib/browserctl/commands/record.rb +2 -0
- data/lib/browserctl/commands/resume.rb +21 -0
- data/lib/browserctl/commands/status.rb +30 -0
- data/lib/browserctl/constants.rb +9 -2
- data/lib/browserctl/logger.rb +40 -5
- data/lib/browserctl/recording.rb +81 -15
- data/lib/browserctl/runner.rb +19 -0
- data/lib/browserctl/server/command_dispatcher.rb +24 -5
- data/lib/browserctl/server.rb +16 -1
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +24 -0
- metadata +24 -4
data/lib/browserctl/client.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "socket"
|
|
4
5
|
require "json"
|
|
5
6
|
require_relative "constants"
|
|
@@ -152,6 +153,30 @@ module Browserctl
|
|
|
152
153
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
153
154
|
def clear_cookies(name) = call("clear_cookies", name: name)
|
|
154
155
|
|
|
156
|
+
# Exports all cookies for a named page to a JSON file.
|
|
157
|
+
# @param name [String] logical page name
|
|
158
|
+
# @param path [String] file path to write cookies to
|
|
159
|
+
# @return [Hash] `{ ok: true, path:, count: }` or `{ error: }`
|
|
160
|
+
def export_cookies(name, path)
|
|
161
|
+
result = call("cookies", name: name)
|
|
162
|
+
return result unless result[:ok]
|
|
163
|
+
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
165
|
+
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(result[:cookies])) }
|
|
166
|
+
{ ok: true, path: path, count: result[:cookies].length }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Imports cookies from a JSON file into a named page.
|
|
170
|
+
# @param name [String] logical page name
|
|
171
|
+
# @param path [String] file path to read cookies from
|
|
172
|
+
# @return [Hash] `{ ok: true, count: }` or `{ error: }`
|
|
173
|
+
def import_cookies(name, path)
|
|
174
|
+
raise "cookie file not found: #{path}" unless File.exist?(path)
|
|
175
|
+
|
|
176
|
+
cookies = JSON.parse(File.read(path), symbolize_names: true)
|
|
177
|
+
call("import_cookies", name: name, cookies: cookies)
|
|
178
|
+
end
|
|
179
|
+
|
|
155
180
|
private
|
|
156
181
|
|
|
157
182
|
def communicate(payload)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class ExportCookies
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
page = args.shift or abort "usage: browserctl export-cookies <page> <path>"
|
|
8
|
+
path = args.shift or abort "usage: browserctl export-cookies <page> <path>"
|
|
9
|
+
result = client.export_cookies(page, path)
|
|
10
|
+
if result[:error]
|
|
11
|
+
warn "Error: #{result[:error]}"
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
puts result.to_json
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class ImportCookies
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
page = args.shift or abort "usage: browserctl import-cookies <page> <path>"
|
|
8
|
+
path = args.shift or abort "usage: browserctl import-cookies <page> <path>"
|
|
9
|
+
begin
|
|
10
|
+
result = client.import_cookies(page, path)
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
warn "Error: #{e.message}"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
if result[:error]
|
|
16
|
+
warn "Error: #{result[:error]}"
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
19
|
+
puts result.to_json
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -13,15 +13,26 @@ module Browserctl
|
|
|
13
13
|
# workflows_dir: .browserctl/workflows
|
|
14
14
|
YAML
|
|
15
15
|
|
|
16
|
+
GITIGNORE_CONTENT = <<~GITIGNORE
|
|
17
|
+
# Cookie session exports — contain credentials, never commit
|
|
18
|
+
sessions/
|
|
19
|
+
GITIGNORE
|
|
20
|
+
|
|
16
21
|
def self.run(_args)
|
|
17
22
|
FileUtils.mkdir_p(".browserctl/workflows")
|
|
18
23
|
FileUtils.touch(".browserctl/workflows/.keep")
|
|
19
24
|
|
|
25
|
+
FileUtils.mkdir_p(".browserctl/sessions")
|
|
26
|
+
|
|
27
|
+
gitignore_path = ".browserctl/.gitignore"
|
|
28
|
+
File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
|
|
29
|
+
|
|
20
30
|
config_path = ".browserctl/config.yml"
|
|
21
31
|
File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
|
|
22
32
|
|
|
23
33
|
puts "Initialised browserctl project:"
|
|
24
34
|
puts " .browserctl/workflows/ (place workflow .rb files here)"
|
|
35
|
+
puts " .browserctl/sessions/ (cookie exports — git-ignored)"
|
|
25
36
|
puts " .browserctl/config.yml (project settings)"
|
|
26
37
|
end
|
|
27
38
|
end
|
|
@@ -4,10 +4,10 @@ require_relative "cli_output"
|
|
|
4
4
|
|
|
5
5
|
module Browserctl
|
|
6
6
|
module Commands
|
|
7
|
-
module
|
|
7
|
+
module Pause
|
|
8
8
|
extend CliOutput
|
|
9
9
|
|
|
10
|
-
def self.
|
|
10
|
+
def self.run(client, args)
|
|
11
11
|
name = args.shift or abort "usage: browserctl pause <page>"
|
|
12
12
|
res = client.pause(name)
|
|
13
13
|
if res[:error]
|
|
@@ -17,16 +17,6 @@ module Browserctl
|
|
|
17
17
|
puts "Page '#{name}' paused. Browser is live — interact freely."
|
|
18
18
|
puts "When done: browserctl resume #{name}"
|
|
19
19
|
end
|
|
20
|
-
|
|
21
|
-
def self.resume(client, args)
|
|
22
|
-
name = args.shift or abort "usage: browserctl resume <page>"
|
|
23
|
-
res = client.resume(name)
|
|
24
|
-
if res[:error]
|
|
25
|
-
warn "Error: #{res[:error]}"
|
|
26
|
-
exit 1
|
|
27
|
-
end
|
|
28
|
-
puts "Page '#{name}' resumed."
|
|
29
|
-
end
|
|
30
20
|
end
|
|
31
21
|
end
|
|
32
22
|
end
|
|
@@ -26,6 +26,8 @@ module Browserctl
|
|
|
26
26
|
def run_start(args)
|
|
27
27
|
Optimist.options(args) { banner "Usage: browserctl record start <name>" }
|
|
28
28
|
name = args.shift or abort "usage: browserctl record start <name>"
|
|
29
|
+
abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
|
|
30
|
+
unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
29
31
|
Recording.start(name)
|
|
30
32
|
puts "Recording started: #{name}"
|
|
31
33
|
puts "Run browser commands, then: browserctl record stop"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_output"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Resume
|
|
8
|
+
extend CliOutput
|
|
9
|
+
|
|
10
|
+
def self.run(client, args)
|
|
11
|
+
name = args.shift or abort "usage: browserctl resume <page>"
|
|
12
|
+
res = client.resume(name)
|
|
13
|
+
if res[:error]
|
|
14
|
+
warn "Error: #{res[:error]}"
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
puts "Page '#{name}' resumed."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
module Status
|
|
8
|
+
def self.run(client)
|
|
9
|
+
ping = client.ping
|
|
10
|
+
pages = client.list_pages[:pages] || []
|
|
11
|
+
page_info = pages.map do |name|
|
|
12
|
+
url_res = client.url(name)
|
|
13
|
+
{ name: name, url: url_res[:url] || url_res[:error] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
puts JSON.pretty_generate(
|
|
17
|
+
daemon: "online",
|
|
18
|
+
pid: ping[:pid],
|
|
19
|
+
protocol_version: ping[:protocol_version],
|
|
20
|
+
pages: page_info
|
|
21
|
+
)
|
|
22
|
+
rescue RuntimeError => e
|
|
23
|
+
raise unless e.message.include?("browserd is not running")
|
|
24
|
+
|
|
25
|
+
puts JSON.pretty_generate(daemon: "offline", error: e.message)
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/browserctl/constants.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Browserctl
|
|
4
|
-
BROWSERCTL_DIR
|
|
5
|
-
IDLE_TTL
|
|
4
|
+
BROWSERCTL_DIR = File.expand_path("~/.browserctl")
|
|
5
|
+
IDLE_TTL = 30 * 60
|
|
6
|
+
# Increment when a breaking wire protocol change ships (new field names, removed commands, changed response shapes).
|
|
7
|
+
# Clients read this from `ping` to verify compatibility before sending commands.
|
|
8
|
+
PROTOCOL_VERSION = "1"
|
|
6
9
|
|
|
7
10
|
def self.socket_path(name = nil)
|
|
8
11
|
File.join(BROWSERCTL_DIR, name ? "#{name}.sock" : "browserd.sock")
|
|
@@ -12,6 +15,10 @@ module Browserctl
|
|
|
12
15
|
File.join(BROWSERCTL_DIR, name ? "#{name}.pid" : "browserd.pid")
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
def self.log_path(name = nil)
|
|
19
|
+
File.join(BROWSERCTL_DIR, name ? "#{name}.log" : "browserd.log")
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
# Backward-compatible constants
|
|
16
23
|
SOCKET_PATH = socket_path
|
|
17
24
|
PID_PATH = pid_path
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
LEVEL_MAP = {
|
|
@@ -10,6 +11,25 @@ module Browserctl
|
|
|
10
11
|
"error" => ::Logger::ERROR
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
14
|
+
class MultiLogger
|
|
15
|
+
def initialize(*loggers)
|
|
16
|
+
@loggers = loggers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delegate to each logger; swallow individual write failures so a broken file
|
|
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
|
|
25
|
+
|
|
26
|
+
def level = @loggers.first&.level
|
|
27
|
+
|
|
28
|
+
def level=(lvl)
|
|
29
|
+
@loggers.each { |l| l.level = lvl }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
13
33
|
def self.logger
|
|
14
34
|
@logger ||= build_logger("info")
|
|
15
35
|
end
|
|
@@ -18,11 +38,26 @@ module Browserctl
|
|
|
18
38
|
@logger = instance
|
|
19
39
|
end
|
|
20
40
|
|
|
21
|
-
def self.build_logger(level_name)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
def self.build_logger(level_name, log_path: nil)
|
|
42
|
+
level = LEVEL_MAP.fetch(level_name.to_s.downcase, ::Logger::INFO)
|
|
43
|
+
formatter = proc { |sev, t, prog, msg| "#{t.strftime('%Y-%m-%dT%H:%M:%S')} #{sev[0]} [#{prog}] #{msg}\n" }
|
|
44
|
+
|
|
45
|
+
stderr_log = make_logger($stderr, level, formatter)
|
|
46
|
+
return stderr_log unless log_path
|
|
47
|
+
|
|
48
|
+
FileUtils.mkdir_p(File.dirname(log_path), mode: 0o700)
|
|
49
|
+
FileUtils.touch(log_path)
|
|
50
|
+
File.chmod(0o600, log_path)
|
|
51
|
+
file_log = make_logger(log_path, level, formatter)
|
|
52
|
+
MultiLogger.new(stderr_log, file_log)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.make_logger(device, level, formatter)
|
|
56
|
+
log = ::Logger.new(device)
|
|
57
|
+
log.level = level
|
|
58
|
+
log.progname = "browserd"
|
|
59
|
+
log.formatter = formatter
|
|
26
60
|
log
|
|
27
61
|
end
|
|
62
|
+
private_class_method :make_logger
|
|
28
63
|
end
|
data/lib/browserctl/recording.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "date"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "tmpdir"
|
|
7
|
+
require "uri"
|
|
7
8
|
|
|
8
9
|
module Browserctl
|
|
9
10
|
class Recording
|
|
@@ -12,11 +13,15 @@ module Browserctl
|
|
|
12
13
|
|
|
13
14
|
RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
|
|
14
15
|
|
|
16
|
+
SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
|
|
17
|
+
|
|
15
18
|
def self.start(name)
|
|
16
|
-
FileUtils.mkdir_p(RECORDINGS_DIR)
|
|
19
|
+
FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
|
|
17
20
|
FileUtils.mkdir_p(File.dirname(STATE_FILE))
|
|
18
21
|
File.write(STATE_FILE, name)
|
|
19
22
|
FileUtils.rm_f(log_path(name))
|
|
23
|
+
FileUtils.touch(log_path(name))
|
|
24
|
+
File.chmod(0o600, log_path(name))
|
|
20
25
|
name
|
|
21
26
|
end
|
|
22
27
|
|
|
@@ -36,10 +41,13 @@ module Browserctl
|
|
|
36
41
|
name = active
|
|
37
42
|
return unless name
|
|
38
43
|
return unless RECORDABLE.include?(cmd.to_s)
|
|
39
|
-
# ref-based interactions have no replayable selector — skip them
|
|
40
|
-
return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
|
|
46
|
+
record_ref_interaction(name, cmd.to_s, attrs)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attrs = prepare_attrs(cmd.to_s, attrs)
|
|
43
51
|
|
|
44
52
|
File.open(log_path(name), "a") do |f|
|
|
45
53
|
f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
|
|
@@ -53,6 +61,13 @@ module Browserctl
|
|
|
53
61
|
lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
|
|
54
62
|
ruby = build_workflow_ruby(name, lines)
|
|
55
63
|
File.write(output_path, ruby) if output_path
|
|
64
|
+
|
|
65
|
+
ref_count = lines.count { |l| l[:cmd] == "_ref_interaction" }
|
|
66
|
+
if ref_count.positive?
|
|
67
|
+
warn "Warning: #{ref_count} ref-based interaction(s) were captured but cannot be replayed by ref."
|
|
68
|
+
warn "Search the generated workflow for 'TODO: ref-based' and replace with stable CSS selectors."
|
|
69
|
+
end
|
|
70
|
+
|
|
56
71
|
ruby
|
|
57
72
|
ensure
|
|
58
73
|
FileUtils.rm_f(log) if log
|
|
@@ -65,6 +80,13 @@ module Browserctl
|
|
|
65
80
|
File.join(RECORDINGS_DIR, "#{name}.jsonl")
|
|
66
81
|
end
|
|
67
82
|
|
|
83
|
+
def record_ref_interaction(recording_name, cmd, attrs)
|
|
84
|
+
entry = { cmd: "_ref_interaction", action: cmd, ref: attrs[:ref], name: attrs[:name] }
|
|
85
|
+
File.open(log_path(recording_name), "a") do |f|
|
|
86
|
+
f.puts JSON.generate(entry)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
68
90
|
def build_workflow_ruby(name, commands)
|
|
69
91
|
steps = commands.map { |c| build_step(c) }.join("\n\n")
|
|
70
92
|
<<~RUBY
|
|
@@ -80,30 +102,74 @@ module Browserctl
|
|
|
80
102
|
|
|
81
103
|
def build_step(cmd)
|
|
82
104
|
label, body = step_parts(cmd)
|
|
83
|
-
|
|
105
|
+
|
|
106
|
+
if body.nil?
|
|
107
|
+
page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
108
|
+
action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
|
|
109
|
+
return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
|
|
110
|
+
"replace with a stable CSS selector\n" \
|
|
111
|
+
"# step #{label.inspect} do\n" \
|
|
112
|
+
"# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
|
|
113
|
+
"# end"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
url = cmd[:url].to_s
|
|
117
|
+
if url.include?("[REDACTED]")
|
|
118
|
+
"# NOTE: sensitive query params were redacted during recording\nstep #{label.inspect} do\n #{body}\nend"
|
|
119
|
+
else
|
|
120
|
+
"step #{label.inspect} do\n #{body}\nend"
|
|
121
|
+
end
|
|
84
122
|
end
|
|
85
123
|
|
|
86
124
|
def step_parts(cmd)
|
|
125
|
+
return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
|
|
126
|
+
return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
|
|
127
|
+
|
|
128
|
+
page = cmd[:name]
|
|
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})"]
|
|
132
|
+
when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
133
|
+
when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
134
|
+
else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ref_interaction_parts(cmd)
|
|
139
|
+
["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def selector_parts(cmd)
|
|
87
143
|
page = cmd[:name]
|
|
88
144
|
case cmd[:cmd]
|
|
89
|
-
when "open_page"
|
|
90
|
-
["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
91
|
-
when "goto"
|
|
92
|
-
["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
|
|
93
145
|
when "fill"
|
|
94
146
|
["fill #{cmd[:selector]} on #{page}",
|
|
95
147
|
"page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
|
|
96
148
|
when "click"
|
|
97
149
|
["click #{cmd[:selector]} on #{page}",
|
|
98
150
|
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
99
|
-
when "screenshot"
|
|
100
|
-
["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
101
|
-
when "evaluate"
|
|
102
|
-
["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
103
|
-
else
|
|
104
|
-
["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
105
151
|
end
|
|
106
152
|
end
|
|
153
|
+
|
|
154
|
+
def prepare_attrs(cmd, attrs)
|
|
155
|
+
attrs = attrs.except(:value) if cmd == "fill"
|
|
156
|
+
attrs[:url] = redact_url(attrs[:url]) if %w[goto open_page].include?(cmd) && attrs[:url]
|
|
157
|
+
attrs
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def redact_url(url)
|
|
161
|
+
uri = URI.parse(url)
|
|
162
|
+
return url if uri.query.nil?
|
|
163
|
+
|
|
164
|
+
uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
|
|
165
|
+
raw_key = ::Regexp.last_match(1)
|
|
166
|
+
key = URI.decode_www_form_component(raw_key)
|
|
167
|
+
key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
|
|
168
|
+
end
|
|
169
|
+
uri.to_s
|
|
170
|
+
rescue URI::InvalidURIError
|
|
171
|
+
url
|
|
172
|
+
end
|
|
107
173
|
end
|
|
108
174
|
end
|
|
109
175
|
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "workflow"
|
|
4
5
|
require_relative "client"
|
|
5
6
|
|
|
@@ -39,6 +40,24 @@ module Browserctl
|
|
|
39
40
|
|
|
40
41
|
SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
41
42
|
|
|
43
|
+
def self.load_params_file(path)
|
|
44
|
+
raise "params file not found: #{path}" unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
case File.extname(path).downcase
|
|
47
|
+
when ".yml", ".yaml"
|
|
48
|
+
require "yaml"
|
|
49
|
+
YAML.safe_load_file(path, symbolize_names: true)
|
|
50
|
+
when ".json"
|
|
51
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
52
|
+
else
|
|
53
|
+
raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
54
|
+
end
|
|
55
|
+
rescue Psych::SyntaxError => e
|
|
56
|
+
raise "invalid YAML in #{path}: #{e.message}"
|
|
57
|
+
rescue JSON::ParserError => e
|
|
58
|
+
raise "invalid JSON in #{path}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
42
61
|
private
|
|
43
62
|
|
|
44
63
|
def validate_name!(name)
|
|
@@ -25,10 +25,12 @@ module Browserctl
|
|
|
25
25
|
"inspect" => :cmd_inspect,
|
|
26
26
|
"cookies" => :cmd_cookies,
|
|
27
27
|
"set_cookie" => :cmd_set_cookie,
|
|
28
|
-
"clear_cookies" => :cmd_clear_cookies
|
|
28
|
+
"clear_cookies" => :cmd_clear_cookies,
|
|
29
|
+
"import_cookies" => :cmd_import_cookies
|
|
29
30
|
}.freeze
|
|
30
31
|
|
|
31
|
-
SCREENSHOT_DIR
|
|
32
|
+
SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
|
|
33
|
+
SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
|
|
32
34
|
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
33
35
|
CLOUDFLARE_SIGNALS = [
|
|
34
36
|
"cf-challenge-running",
|
|
@@ -168,8 +170,8 @@ module Browserctl
|
|
|
168
170
|
def safe_screenshot_path(requested, page_name)
|
|
169
171
|
if requested
|
|
170
172
|
expanded = File.expand_path(requested)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
allowed = SCREENSHOT_ROOTS.any? { |d| expanded.start_with?("#{d}/") || expanded.start_with?(d) }
|
|
174
|
+
return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } unless allowed
|
|
173
175
|
return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
|
|
174
176
|
unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
|
|
175
177
|
|
|
@@ -235,6 +237,23 @@ module Browserctl
|
|
|
235
237
|
{ ok: true }
|
|
236
238
|
end
|
|
237
239
|
|
|
240
|
+
def cmd_import_cookies(req)
|
|
241
|
+
with_page(req[:name]) do |session|
|
|
242
|
+
req[:cookies].each do |c|
|
|
243
|
+
session.page.cookies.set(
|
|
244
|
+
name: c[:name],
|
|
245
|
+
value: c[:value],
|
|
246
|
+
domain: c[:domain],
|
|
247
|
+
path: c.fetch(:path, "/"),
|
|
248
|
+
httponly: c[:httpOnly] == true,
|
|
249
|
+
secure: c[:secure] == true,
|
|
250
|
+
expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
{ ok: true, count: req[:cookies].length }
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
238
257
|
def cmd_inspect(req)
|
|
239
258
|
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
240
259
|
return { error: "no page named '#{req[:name]}'" } unless session
|
|
@@ -265,7 +284,7 @@ module Browserctl
|
|
|
265
284
|
{ ok: true, paused: false }
|
|
266
285
|
end
|
|
267
286
|
|
|
268
|
-
def cmd_ping(_req) = { ok: true, pid: Process.pid }
|
|
287
|
+
def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
|
|
269
288
|
|
|
270
289
|
def cmd_shutdown(_req)
|
|
271
290
|
Process.kill("INT", Process.pid)
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Browserctl
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run
|
|
24
|
+
guard_already_running!
|
|
24
25
|
write_pid
|
|
25
26
|
server, idle = setup_server
|
|
26
27
|
serve(server)
|
|
@@ -63,10 +64,24 @@ module Browserctl
|
|
|
63
64
|
FileUtils.rm_f(@socket_path)
|
|
64
65
|
server = UNIXServer.new(@socket_path)
|
|
65
66
|
File.chmod(0o600, @socket_path)
|
|
66
|
-
Browserctl.logger.info "listening on #{@socket_path}"
|
|
67
|
+
Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
|
|
67
68
|
server
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
def guard_already_running!
|
|
72
|
+
return unless File.exist?(@pid_path)
|
|
73
|
+
|
|
74
|
+
pid = File.read(@pid_path).strip.to_i
|
|
75
|
+
return unless pid.positive?
|
|
76
|
+
|
|
77
|
+
Process.kill(0, pid)
|
|
78
|
+
abort "browserd already running (PID #{pid}). Use 'browserctl shutdown' first."
|
|
79
|
+
rescue Errno::ESRCH
|
|
80
|
+
# Dead process — stale PID file, safe to continue
|
|
81
|
+
rescue Errno::EPERM
|
|
82
|
+
abort "browserd (PID #{pid}) is running as a different user. Remove #{@pid_path} manually if stale."
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
def serve(server)
|
|
71
86
|
loop do
|
|
72
87
|
client = server.accept
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -16,6 +16,15 @@ module Browserctl
|
|
|
16
16
|
def initialize(params, client)
|
|
17
17
|
@params = params
|
|
18
18
|
@client = client
|
|
19
|
+
@_store = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def store(key, value)
|
|
23
|
+
@_store[key] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(key)
|
|
27
|
+
@_store.fetch(key) { raise KeyError, "no value stored for key #{key.inspect}" }
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
def method_missing(name, *args)
|
|
@@ -32,6 +41,20 @@ module Browserctl
|
|
|
32
41
|
PageProxy.new(name.to_s, @client)
|
|
33
42
|
end
|
|
34
43
|
|
|
44
|
+
def open_page(page_name, url: nil)
|
|
45
|
+
res = @client.open_page(page_name.to_s, url: url)
|
|
46
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
47
|
+
|
|
48
|
+
res
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def close_page(page_name)
|
|
52
|
+
res = @client.close_page(page_name.to_s)
|
|
53
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
54
|
+
|
|
55
|
+
res
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
def invoke(workflow_name, **override_params)
|
|
36
59
|
name = workflow_name.to_s
|
|
37
60
|
guard_circular!(name)
|
|
@@ -77,6 +100,7 @@ module Browserctl
|
|
|
77
100
|
def click(sel) = unwrap @client.click(@name, sel)
|
|
78
101
|
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
79
102
|
def screenshot(**) = unwrap @client.screenshot(@name, **)
|
|
103
|
+
def watch(sel, timeout: 30) = unwrap @client.watch(@name, sel, timeout: timeout)
|
|
80
104
|
def wait_for(sel, timeout: 10) = unwrap @client.wait_for(@name, sel, timeout: timeout)
|
|
81
105
|
def url = @client.url(@name)[:url]
|
|
82
106
|
def evaluate(expr) = @client.evaluate(@name, expr)[:result]
|