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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module PageLifecycle
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def cmd_page_open(req)
|
|
10
|
+
session = @global_mutex.synchronize do
|
|
11
|
+
@pages[req[:name]] ||= PageSession.new(@browser.create_page)
|
|
12
|
+
end
|
|
13
|
+
session.page.go_to(req[:url]) if req[:url]
|
|
14
|
+
{ ok: true, name: req[:name] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cmd_page_close(req)
|
|
18
|
+
session = @global_mutex.synchronize { @pages.delete(req[:name]) }
|
|
19
|
+
session&.page&.close
|
|
20
|
+
{ ok: true }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cmd_page_list(_req)
|
|
24
|
+
{ pages: @global_mutex.synchronize { @pages.keys } }
|
|
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
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
module Storage
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Returns { ok: true, value: } or { error: }
|
|
10
|
+
def cmd_storage_get(req)
|
|
11
|
+
with_page(req[:name]) do |session|
|
|
12
|
+
store = req.fetch(:store, "local")
|
|
13
|
+
js = storage_js_get(store, req[:key])
|
|
14
|
+
return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
|
|
15
|
+
|
|
16
|
+
value = session.page.evaluate(js)
|
|
17
|
+
{ ok: true, value: value }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns { ok: true } or { error: }
|
|
22
|
+
def cmd_storage_set(req)
|
|
23
|
+
with_page(req[:name]) do |session|
|
|
24
|
+
store = req.fetch(:store, "local")
|
|
25
|
+
js = storage_js_set(store, req[:key], req[:value])
|
|
26
|
+
return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
|
|
27
|
+
|
|
28
|
+
session.page.evaluate(js)
|
|
29
|
+
{ ok: true }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exports localStorage and/or sessionStorage to a JSON file.
|
|
34
|
+
# Returns { ok: true, path:, key_count: } or { error: }
|
|
35
|
+
def cmd_storage_export(req)
|
|
36
|
+
with_page(req[:name]) do |session|
|
|
37
|
+
stores = req.fetch(:stores, "all")
|
|
38
|
+
data = {}
|
|
39
|
+
|
|
40
|
+
origin = session.page.evaluate("location.origin")
|
|
41
|
+
data[origin] = {}
|
|
42
|
+
|
|
43
|
+
if %w[local all].include?(stores)
|
|
44
|
+
local = JSON.parse(session.page.evaluate("JSON.stringify({...localStorage})") || "{}")
|
|
45
|
+
data[origin].merge!(local)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if %w[session all].include?(stores)
|
|
49
|
+
sess = JSON.parse(session.page.evaluate("JSON.stringify({...sessionStorage})") || "{}")
|
|
50
|
+
data[origin].merge!(sess)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
path = File.expand_path(req[:path])
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
55
|
+
File.write(path, JSON.generate(data))
|
|
56
|
+
{ ok: true, path: path, key_count: data[origin].length }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Imports keys from a JSON file into the page's localStorage only.
|
|
61
|
+
# sessionStorage keys in the file are ignored (sessionStorage is tab-scoped and not restorable).
|
|
62
|
+
# Returns { ok: true, origins: N, key_count: M } or { error: }
|
|
63
|
+
def cmd_storage_import(req)
|
|
64
|
+
path = File.expand_path(req[:path])
|
|
65
|
+
return { error: "file not found: #{path}" } unless File.exist?(path)
|
|
66
|
+
|
|
67
|
+
data = JSON.parse(File.read(path))
|
|
68
|
+
return { error: "invalid storage file format" } unless data.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
with_page(req[:name]) do |session|
|
|
71
|
+
key_count = 0
|
|
72
|
+
data.each_value do |keys|
|
|
73
|
+
keys.each do |k, v|
|
|
74
|
+
session.page.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
|
|
75
|
+
key_count += 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
{ ok: true, origins: data.length, key_count: key_count }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Clears localStorage and/or sessionStorage for the page.
|
|
83
|
+
# Returns { ok: true } or { error: }
|
|
84
|
+
def cmd_storage_delete(req)
|
|
85
|
+
with_page(req[:name]) do |session|
|
|
86
|
+
stores = req.fetch(:stores, "all")
|
|
87
|
+
session.page.evaluate("localStorage.clear()") if %w[local all].include?(stores)
|
|
88
|
+
session.page.evaluate("sessionStorage.clear()") if %w[session all].include?(stores)
|
|
89
|
+
{ ok: true }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def storage_js_get(store, key)
|
|
94
|
+
case store
|
|
95
|
+
when "local" then "localStorage.getItem(#{key.to_json})"
|
|
96
|
+
when "session" then "sessionStorage.getItem(#{key.to_json})"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def storage_js_set(store, key, value)
|
|
101
|
+
case store
|
|
102
|
+
when "local" then "localStorage.setItem(#{key.to_json}, #{value.to_json})"
|
|
103
|
+
when "session" then "sessionStorage.setItem(#{key.to_json}, #{value.to_json})"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -62,8 +62,9 @@ module Browserctl
|
|
|
62
62
|
|
|
63
63
|
def setup_socket
|
|
64
64
|
FileUtils.rm_f(@socket_path)
|
|
65
|
+
old_umask = File.umask(0o177)
|
|
65
66
|
server = UNIXServer.new(@socket_path)
|
|
66
|
-
File.
|
|
67
|
+
File.umask(old_umask)
|
|
67
68
|
Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
|
|
68
69
|
server
|
|
69
70
|
end
|
|
@@ -75,7 +76,7 @@ module Browserctl
|
|
|
75
76
|
return unless pid.positive?
|
|
76
77
|
|
|
77
78
|
Process.kill(0, pid)
|
|
78
|
-
abort "browserd already running (PID #{pid}). Use 'browserctl
|
|
79
|
+
abort "browserd already running (PID #{pid}). Use 'browserctl daemon stop' first."
|
|
79
80
|
rescue Errno::ESRCH
|
|
80
81
|
# Dead process — stale PID file, safe to continue
|
|
81
82
|
rescue Errno::EPERM
|
|
@@ -124,7 +125,7 @@ module Browserctl
|
|
|
124
125
|
|
|
125
126
|
def quietly
|
|
126
127
|
yield
|
|
127
|
-
rescue Exception
|
|
128
|
+
rescue Exception
|
|
128
129
|
nil
|
|
129
130
|
end
|
|
130
131
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "constants"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
class Session
|
|
9
|
+
BASE_DIR = File.join(BROWSERCTL_DIR, "sessions")
|
|
10
|
+
|
|
11
|
+
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
12
|
+
|
|
13
|
+
def self.path(name) = File.join(BASE_DIR, name)
|
|
14
|
+
def self.exist?(name) = Dir.exist?(path(name))
|
|
15
|
+
|
|
16
|
+
def self.delete(name)
|
|
17
|
+
validate_name!(name)
|
|
18
|
+
FileUtils.rm_rf(path(name))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.all
|
|
22
|
+
Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:)
|
|
26
|
+
validate_name!(session_name)
|
|
27
|
+
dir = path(session_name)
|
|
28
|
+
FileUtils.mkdir_p(dir)
|
|
29
|
+
write_json(File.join(dir, "metadata.json"), metadata)
|
|
30
|
+
write_secret(File.join(dir, "cookies.json"), cookies)
|
|
31
|
+
write_secret(File.join(dir, "local_storage.json"), local_storage)
|
|
32
|
+
return if session_storage.empty?
|
|
33
|
+
|
|
34
|
+
write_secret(File.join(dir, "session_storage.json"), session_storage)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.load(session_name)
|
|
38
|
+
validate_name!(session_name)
|
|
39
|
+
dir = path(session_name)
|
|
40
|
+
raise "session '#{session_name}' not found" unless Dir.exist?(dir)
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
metadata: JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true),
|
|
44
|
+
cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
|
|
45
|
+
local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
|
|
46
|
+
session_storage: load_session_storage(dir)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.load_meta(path)
|
|
51
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.validate_name!(name)
|
|
57
|
+
return if SAFE_NAME.match?(name.to_s)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.load_session_storage(dir)
|
|
63
|
+
ss_path = File.join(dir, "session_storage.json")
|
|
64
|
+
File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
|
|
65
|
+
end
|
|
66
|
+
private_class_method :load_session_storage
|
|
67
|
+
|
|
68
|
+
def self.write_json(path, data)
|
|
69
|
+
File.write(path, JSON.generate(data))
|
|
70
|
+
end
|
|
71
|
+
private_class_method :write_json
|
|
72
|
+
|
|
73
|
+
# Cookies and storage contain secrets — restrict to owner read/write only.
|
|
74
|
+
def self.write_secret(path, data)
|
|
75
|
+
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
76
|
+
end
|
|
77
|
+
private_class_method :write_secret
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -16,15 +16,20 @@ module Browserctl
|
|
|
16
16
|
def initialize(params, client)
|
|
17
17
|
@params = params
|
|
18
18
|
@client = client
|
|
19
|
-
@_store = {}
|
|
20
19
|
end
|
|
21
20
|
|
|
22
21
|
def store(key, value)
|
|
23
|
-
@
|
|
22
|
+
res = @client.store(key.to_s, value)
|
|
23
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
24
|
+
|
|
25
|
+
value
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def fetch(key)
|
|
27
|
-
@
|
|
29
|
+
res = @client.fetch(key.to_s)
|
|
30
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
31
|
+
|
|
32
|
+
res[:value]
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
def method_missing(name, *args)
|
|
@@ -42,19 +47,37 @@ module Browserctl
|
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
def open_page(page_name, url: nil)
|
|
45
|
-
res = @client.
|
|
50
|
+
res = @client.page_open(page_name.to_s, url: url)
|
|
46
51
|
raise WorkflowError, res[:error] if res[:error]
|
|
47
52
|
|
|
48
53
|
res
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
def close_page(page_name)
|
|
52
|
-
res = @client.
|
|
57
|
+
res = @client.page_close(page_name.to_s)
|
|
58
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
59
|
+
|
|
60
|
+
res
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def save_session(session_name)
|
|
64
|
+
res = @client.session_save(session_name)
|
|
65
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
66
|
+
|
|
67
|
+
res
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def load_session(session_name)
|
|
71
|
+
res = @client.session_load(session_name)
|
|
53
72
|
raise WorkflowError, res[:error] if res[:error]
|
|
54
73
|
|
|
55
74
|
res
|
|
56
75
|
end
|
|
57
76
|
|
|
77
|
+
def list_sessions
|
|
78
|
+
@client.session_list[:sessions]
|
|
79
|
+
end
|
|
80
|
+
|
|
58
81
|
def invoke(workflow_name, **override_params)
|
|
59
82
|
name = workflow_name.to_s
|
|
60
83
|
guard_circular!(name)
|
|
@@ -95,15 +118,24 @@ module Browserctl
|
|
|
95
118
|
@client = client
|
|
96
119
|
end
|
|
97
120
|
|
|
98
|
-
def
|
|
99
|
-
def fill(sel, val)
|
|
100
|
-
def click(sel)
|
|
101
|
-
def snapshot(**)
|
|
102
|
-
def screenshot(**)
|
|
103
|
-
def
|
|
104
|
-
def
|
|
105
|
-
def
|
|
106
|
-
def
|
|
121
|
+
def navigate(url) = unwrap @client.navigate(@name, url)
|
|
122
|
+
def fill(sel, val) = unwrap @client.fill(@name, sel, val)
|
|
123
|
+
def click(sel) = unwrap @client.click(@name, sel)
|
|
124
|
+
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
125
|
+
def screenshot(**) = unwrap @client.screenshot(@name, **)
|
|
126
|
+
def wait(sel, timeout: 30) = unwrap @client.wait(@name, sel, timeout: timeout)
|
|
127
|
+
def delete_cookies = unwrap @client.delete_cookies(@name)
|
|
128
|
+
def devtools = @client.devtools(@name)[:devtools_url]
|
|
129
|
+
def url = @client.url(@name)[:url]
|
|
130
|
+
def evaluate(expr) = @client.evaluate(@name, expr)[:result]
|
|
131
|
+
|
|
132
|
+
def storage_get(key, store: "local")
|
|
133
|
+
@client.storage_get(@name, key, store: store)[:value]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def storage_set(key, value, store: "local")
|
|
137
|
+
unwrap @client.storage_set(@name, key, value, store: store)
|
|
138
|
+
end
|
|
107
139
|
|
|
108
140
|
private
|
|
109
141
|
|
|
@@ -137,7 +169,7 @@ module Browserctl
|
|
|
137
169
|
end
|
|
138
170
|
|
|
139
171
|
def compose(workflow_name)
|
|
140
|
-
source =
|
|
172
|
+
source = Browserctl.lookup_workflow(workflow_name.to_s)
|
|
141
173
|
raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
|
|
142
174
|
|
|
143
175
|
@steps.concat(source.steps)
|
|
@@ -187,11 +219,20 @@ module Browserctl
|
|
|
187
219
|
end
|
|
188
220
|
end
|
|
189
221
|
|
|
190
|
-
|
|
222
|
+
@registry_mutex = Mutex.new
|
|
223
|
+
@registry = {}
|
|
191
224
|
|
|
192
225
|
def self.workflow(name, &)
|
|
193
226
|
defn = WorkflowDefinition.new(name.to_s)
|
|
194
227
|
defn.instance_exec(&)
|
|
195
|
-
|
|
228
|
+
@registry_mutex.synchronize { @registry[name.to_s] = defn }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.lookup_workflow(name)
|
|
232
|
+
@registry_mutex.synchronize { @registry[name.to_s] }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def self.registry_snapshot
|
|
236
|
+
@registry_mutex.synchronize { @registry.dup }
|
|
196
237
|
end
|
|
197
238
|
end
|
data/lib/browserctl.rb
CHANGED
|
@@ -2,14 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "browserctl/version"
|
|
4
4
|
require_relative "browserctl/constants"
|
|
5
|
+
require_relative "browserctl/errors"
|
|
5
6
|
require_relative "browserctl/workflow"
|
|
6
7
|
require_relative "browserctl/runner"
|
|
7
8
|
require_relative "browserctl/client"
|
|
8
9
|
|
|
9
10
|
module Browserctl
|
|
10
|
-
|
|
11
|
+
@plugin_commands_mutex = Mutex.new
|
|
12
|
+
@plugin_commands = {}
|
|
11
13
|
|
|
12
14
|
def self.register_command(name, &block)
|
|
13
|
-
|
|
15
|
+
@plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] = block }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.lookup_plugin_command(name)
|
|
19
|
+
@plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.plugin_commands_snapshot
|
|
23
|
+
@plugin_commands_mutex.synchronize { @plugin_commands.dup }
|
|
14
24
|
end
|
|
15
25
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -58,14 +58,28 @@ dependencies:
|
|
|
58
58
|
requirements:
|
|
59
59
|
- - "~>"
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '1
|
|
61
|
+
version: '2.1'
|
|
62
62
|
type: :development
|
|
63
63
|
prerelease: false
|
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
65
|
requirements:
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '1
|
|
68
|
+
version: '2.1'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rake
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '13.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '13.0'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
84
|
name: rspec
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -127,6 +141,12 @@ files:
|
|
|
127
141
|
- examples/cloudflare_hitl.rb
|
|
128
142
|
- examples/smoke/params_file.rb
|
|
129
143
|
- examples/smoke/store_fetch.rb
|
|
144
|
+
- examples/test_automation_practices/checkboxes.rb
|
|
145
|
+
- examples/test_automation_practices/dynamic_elements.rb
|
|
146
|
+
- examples/test_automation_practices/key_press.rb
|
|
147
|
+
- examples/test_automation_practices/login.rb
|
|
148
|
+
- examples/test_automation_practices/login_negative.rb
|
|
149
|
+
- examples/test_automation_practices/notifications.rb
|
|
130
150
|
- examples/the_internet/add_remove_elements.rb
|
|
131
151
|
- examples/the_internet/checkboxes.rb
|
|
132
152
|
- examples/the_internet/dropdown.rb
|
|
@@ -136,28 +156,40 @@ files:
|
|
|
136
156
|
- lib/browserctl/client.rb
|
|
137
157
|
- lib/browserctl/commands/cli_output.rb
|
|
138
158
|
- lib/browserctl/commands/click.rb
|
|
139
|
-
- lib/browserctl/commands/
|
|
159
|
+
- lib/browserctl/commands/cookie.rb
|
|
160
|
+
- lib/browserctl/commands/daemon.rb
|
|
140
161
|
- lib/browserctl/commands/fill.rb
|
|
141
|
-
- lib/browserctl/commands/import_cookies.rb
|
|
142
162
|
- lib/browserctl/commands/init.rb
|
|
143
|
-
- lib/browserctl/commands/
|
|
144
|
-
- lib/browserctl/commands/open_page.rb
|
|
145
|
-
- lib/browserctl/commands/pause.rb
|
|
163
|
+
- lib/browserctl/commands/page.rb
|
|
146
164
|
- lib/browserctl/commands/record.rb
|
|
147
165
|
- lib/browserctl/commands/resume.rb
|
|
148
166
|
- lib/browserctl/commands/screenshot.rb
|
|
167
|
+
- lib/browserctl/commands/session.rb
|
|
149
168
|
- lib/browserctl/commands/snapshot.rb
|
|
150
|
-
- lib/browserctl/commands/
|
|
151
|
-
- lib/browserctl/commands/
|
|
169
|
+
- lib/browserctl/commands/storage.rb
|
|
170
|
+
- lib/browserctl/commands/workflow.rb
|
|
152
171
|
- lib/browserctl/constants.rb
|
|
172
|
+
- lib/browserctl/detectors.rb
|
|
173
|
+
- lib/browserctl/errors.rb
|
|
153
174
|
- lib/browserctl/logger.rb
|
|
175
|
+
- lib/browserctl/policy.rb
|
|
154
176
|
- lib/browserctl/recording.rb
|
|
155
177
|
- lib/browserctl/runner.rb
|
|
156
178
|
- lib/browserctl/server.rb
|
|
157
179
|
- lib/browserctl/server/command_dispatcher.rb
|
|
180
|
+
- lib/browserctl/server/handlers/cookies.rb
|
|
181
|
+
- lib/browserctl/server/handlers/daemon_control.rb
|
|
182
|
+
- lib/browserctl/server/handlers/devtools.rb
|
|
183
|
+
- lib/browserctl/server/handlers/hitl.rb
|
|
184
|
+
- lib/browserctl/server/handlers/navigation.rb
|
|
185
|
+
- lib/browserctl/server/handlers/observation.rb
|
|
186
|
+
- lib/browserctl/server/handlers/page_lifecycle.rb
|
|
187
|
+
- lib/browserctl/server/handlers/session.rb
|
|
188
|
+
- lib/browserctl/server/handlers/storage.rb
|
|
158
189
|
- lib/browserctl/server/idle_watcher.rb
|
|
159
190
|
- lib/browserctl/server/page_session.rb
|
|
160
191
|
- lib/browserctl/server/snapshot_builder.rb
|
|
192
|
+
- lib/browserctl/session.rb
|
|
161
193
|
- lib/browserctl/version.rb
|
|
162
194
|
- lib/browserctl/workflow.rb
|
|
163
195
|
homepage: https://github.com/patrick204nqh/browserctl
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Browserctl
|
|
4
|
-
module Commands
|
|
5
|
-
class Inspect
|
|
6
|
-
def self.run(client, args)
|
|
7
|
-
name = args.shift or abort "usage: browserctl inspect <page>"
|
|
8
|
-
res = client.inspect_page(name)
|
|
9
|
-
if res[:error]
|
|
10
|
-
warn "Error: #{res[:error]}"
|
|
11
|
-
exit 1
|
|
12
|
-
end
|
|
13
|
-
url = res[:devtools_url]
|
|
14
|
-
puts "Opening DevTools for '#{name}':"
|
|
15
|
-
puts " #{url}"
|
|
16
|
-
opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
|
|
17
|
-
system(opener, url)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|