browserctl 0.1.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +272 -0
- data/bin/browserctl +92 -0
- data/bin/browserd +10 -0
- data/bin/setup +16 -0
- data/examples/the_internet/add_remove_elements.rb +31 -0
- data/examples/the_internet/checkboxes.rb +35 -0
- data/examples/the_internet/dropdown.rb +33 -0
- data/examples/the_internet/dynamic_loading.rb +33 -0
- data/examples/the_internet/login.rb +33 -0
- data/lib/browserctl/client.rb +53 -0
- data/lib/browserctl/commands/click.rb +13 -0
- data/lib/browserctl/commands/fill.rb +13 -0
- data/lib/browserctl/commands/flag_extractor.rb +15 -0
- data/lib/browserctl/commands/open_page.rb +16 -0
- data/lib/browserctl/commands/screenshot.rb +16 -0
- data/lib/browserctl/commands/snapshot.rb +27 -0
- data/lib/browserctl/constants.rb +7 -0
- data/lib/browserctl/runner.rb +75 -0
- data/lib/browserctl/server/command_dispatcher.rb +135 -0
- data/lib/browserctl/server/idle_watcher.rb +39 -0
- data/lib/browserctl/server/snapshot_builder.rb +55 -0
- data/lib/browserctl/server.rb +112 -0
- data/lib/browserctl/version.rb +5 -0
- data/lib/browserctl/workflow.rb +150 -0
- data/lib/browserctl.rb +7 -0
- metadata +148 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class Click
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
name, selector = args
|
|
8
|
+
abort "usage: browserctl click <page> <selector>" unless name && selector
|
|
9
|
+
puts client.click(name, selector).to_json
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Commands
|
|
5
|
+
class Fill
|
|
6
|
+
def self.run(client, args)
|
|
7
|
+
name, selector, value = args
|
|
8
|
+
abort "usage: browserctl fill <page> <selector> <value>" unless name && selector && value
|
|
9
|
+
puts client.fill(name, selector, value).to_json
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flag_extractor"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
class OpenPage
|
|
8
|
+
def self.run(client, args)
|
|
9
|
+
name = args.shift or abort "usage: browserctl open <name> [--url URL]"
|
|
10
|
+
url = FlagExtractor.extract_opt(args, "--url")
|
|
11
|
+
res = client.open_page(name, url: url)
|
|
12
|
+
puts res.to_json
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "flag_extractor"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
class Screenshot
|
|
8
|
+
def self.run(client, args)
|
|
9
|
+
name = args.shift or abort "usage: browserctl shot <page> [--out PATH] [--full]"
|
|
10
|
+
path = FlagExtractor.extract_opt(args, "--out")
|
|
11
|
+
full = args.delete("--full") ? true : false
|
|
12
|
+
puts client.screenshot(name, path: path, full: full).to_json
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "flag_extractor"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Commands
|
|
8
|
+
class Snapshot
|
|
9
|
+
def self.run(client, args)
|
|
10
|
+
name = args.shift or abort "usage: browserctl snapshot <page> [--format ai|html]"
|
|
11
|
+
format = FlagExtractor.extract_opt(args, "--format") || "ai"
|
|
12
|
+
res = client.snapshot(name, format: format)
|
|
13
|
+
output_snapshot(res, format)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def output_snapshot(res, format)
|
|
20
|
+
return abort res[:error] if res[:error]
|
|
21
|
+
|
|
22
|
+
puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "workflow"
|
|
4
|
+
require_relative "client"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
class Runner
|
|
8
|
+
SEARCH_PATHS = [
|
|
9
|
+
"./.browserctl/workflows",
|
|
10
|
+
File.expand_path("~/.browserctl/workflows")
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def run_workflow(name, **params)
|
|
14
|
+
defn = fetch_workflow(name)
|
|
15
|
+
results = defn.call(params, Client.new)
|
|
16
|
+
print_results(results)
|
|
17
|
+
results.all?(&:ok)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def list_workflows
|
|
21
|
+
load_all_workflows
|
|
22
|
+
REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def describe_workflow(name)
|
|
26
|
+
defn = fetch_workflow(name)
|
|
27
|
+
{ name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:first) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def fetch_workflow(name)
|
|
33
|
+
load_workflow_file(name)
|
|
34
|
+
REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_workflow_file(name)
|
|
38
|
+
return if REGISTRY.key?(name.to_s)
|
|
39
|
+
|
|
40
|
+
path = workflow_path(name)
|
|
41
|
+
load path if path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def workflow_path(name)
|
|
45
|
+
SEARCH_PATHS.find do |dir|
|
|
46
|
+
candidate = File.join(dir, "#{name}.rb")
|
|
47
|
+
candidate if File.exist?(candidate)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load_all_workflows
|
|
52
|
+
SEARCH_PATHS.select { |d| Dir.exist?(d) }.each { |d| load_from_dir(d) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_from_dir(dir)
|
|
56
|
+
Dir.glob("#{dir}/*.rb").each do |f|
|
|
57
|
+
load f unless $LOADED_FEATURES.include?(f)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def print_results(results)
|
|
62
|
+
results.each { |r| print_result(r) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def print_result(result)
|
|
66
|
+
label = result.ok ? "[ok] " : "[fail]"
|
|
67
|
+
msg = result.ok ? result.name : "#{result.name}: #{result.error}"
|
|
68
|
+
$stdout.puts " #{label} #{msg}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_params(defn)
|
|
72
|
+
defn.param_defs.transform_values { |p| { required: p.required, secret: p.secret, default: p.default } }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "snapshot_builder"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class CommandDispatcher
|
|
7
|
+
COMMAND_MAP = {
|
|
8
|
+
"open_page" => :cmd_open_page,
|
|
9
|
+
"close_page" => :cmd_close_page,
|
|
10
|
+
"list_pages" => :cmd_list_pages,
|
|
11
|
+
"goto" => :cmd_goto,
|
|
12
|
+
"snapshot" => :cmd_snapshot,
|
|
13
|
+
"evaluate" => :cmd_evaluate,
|
|
14
|
+
"fill" => :cmd_fill,
|
|
15
|
+
"click" => :cmd_click,
|
|
16
|
+
"screenshot" => :cmd_screenshot,
|
|
17
|
+
"wait_for" => :cmd_wait_for,
|
|
18
|
+
"url" => :cmd_url,
|
|
19
|
+
"ping" => :cmd_ping,
|
|
20
|
+
"shutdown" => :cmd_shutdown
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new)
|
|
24
|
+
@pages = pages
|
|
25
|
+
@browser = browser
|
|
26
|
+
@snapshot = snapshot_builder
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dispatch(req)
|
|
30
|
+
handler = COMMAND_MAP[req[:cmd]]
|
|
31
|
+
return { error: "unknown command: #{req[:cmd]}" } unless handler
|
|
32
|
+
|
|
33
|
+
send(handler, req)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def cmd_open_page(req)
|
|
39
|
+
page = @pages[req[:name]] ||= @browser.create_page
|
|
40
|
+
page.go_to(req[:url]) if req[:url]
|
|
41
|
+
{ ok: true, name: req[:name] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cmd_close_page(req)
|
|
45
|
+
@pages.delete(req[:name])&.close
|
|
46
|
+
{ ok: true }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cmd_list_pages(_req)
|
|
50
|
+
{ pages: @pages.keys }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cmd_goto(req)
|
|
54
|
+
with_page(req[:name]) do |p|
|
|
55
|
+
p.go_to(req[:url])
|
|
56
|
+
{ ok: true, url: p.current_url }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cmd_snapshot(req)
|
|
61
|
+
with_page(req[:name]) { |p| build_snapshot(p, req[:format]) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_snapshot(page, format)
|
|
65
|
+
format == "ai" ? { ok: true, snapshot: @snapshot.call(page) } : { ok: true, html: page.body }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cmd_evaluate(req)
|
|
69
|
+
with_page(req[:name]) { |p| { ok: true, result: p.evaluate(req[:expression]) } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cmd_fill(req)
|
|
73
|
+
with_page(req[:name]) { |p| type_into(p, req[:selector], req[:value]) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def type_into(page, selector, value)
|
|
77
|
+
el = page.at_css(selector)
|
|
78
|
+
return { error: "selector not found: #{selector}" } unless el
|
|
79
|
+
|
|
80
|
+
el.focus
|
|
81
|
+
el.type(value)
|
|
82
|
+
{ ok: true }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cmd_click(req)
|
|
86
|
+
with_page(req[:name]) { |p| click_element(p, req[:selector]) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def click_element(page, selector)
|
|
90
|
+
el = page.at_css(selector)
|
|
91
|
+
return { error: "selector not found: #{selector}" } unless el
|
|
92
|
+
|
|
93
|
+
el.click
|
|
94
|
+
{ ok: true }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def cmd_screenshot(req)
|
|
98
|
+
with_page(req[:name]) do |p|
|
|
99
|
+
path = req[:path] || "/tmp/browserctl_shot_#{req[:name]}_#{Time.now.to_i}.png"
|
|
100
|
+
p.screenshot(path: path, full: req.fetch(:full, false))
|
|
101
|
+
{ ok: true, path: path }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cmd_wait_for(req)
|
|
106
|
+
with_page(req[:name]) { |p| wait_for_selector(p, req[:selector], req.fetch(:timeout, 10).to_f) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def wait_for_selector(page, selector, timeout)
|
|
110
|
+
deadline = Time.now + timeout
|
|
111
|
+
sleep 0.2 until (found = page.at_css(selector)) || Time.now > deadline
|
|
112
|
+
found ? { ok: true } : { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cmd_url(req)
|
|
116
|
+
with_page(req[:name]) { |p| { ok: true, url: p.current_url } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def cmd_ping(_req)
|
|
120
|
+
{ ok: true, pid: Process.pid }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def cmd_shutdown(_req)
|
|
124
|
+
Process.kill("INT", Process.pid)
|
|
125
|
+
{ ok: true }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def with_page(name)
|
|
129
|
+
page = @pages[name]
|
|
130
|
+
return { error: "no page named '#{name}'" } unless page
|
|
131
|
+
|
|
132
|
+
yield page
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class IdleWatcher
|
|
7
|
+
def initialize(last_used_fn)
|
|
8
|
+
@last_used_fn = last_used_fn
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def watch(server)
|
|
12
|
+
loop do
|
|
13
|
+
sleep 60
|
|
14
|
+
if idle?
|
|
15
|
+
shutdown(server)
|
|
16
|
+
break
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def idle?
|
|
24
|
+
Time.now - @last_used_fn.call > IDLE_TTL
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def shutdown(server)
|
|
28
|
+
$stdout.puts "browserd idle timeout, shutting down"
|
|
29
|
+
quietly { server.close }
|
|
30
|
+
Process.kill("INT", Process.pid)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def quietly
|
|
34
|
+
yield
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class SnapshotBuilder
|
|
7
|
+
INTERACTABLE = %w[a button input select textarea
|
|
8
|
+
[role=button] [role=link] [role=menuitem]].freeze
|
|
9
|
+
ATTRS = %w[type name placeholder href aria-label role].freeze
|
|
10
|
+
|
|
11
|
+
def call(page)
|
|
12
|
+
doc = Nokogiri::HTML(page.body)
|
|
13
|
+
ref = 0
|
|
14
|
+
doc.css(INTERACTABLE.join(",")).map { |el| element_entry(el, ref += 1) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def element_entry(elem, ref)
|
|
20
|
+
{ ref: "e#{ref}", tag: elem.name, text: elem.text.strip.slice(0, 80),
|
|
21
|
+
selector: css_path(elem), attrs: element_attrs(elem) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def element_attrs(elem)
|
|
25
|
+
elem.attributes.transform_values(&:value).slice(*ATTRS)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def css_path(node)
|
|
29
|
+
ancestors_until_html(node).map { |n| path_segment(n) }.join(" > ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ancestors_until_html(node)
|
|
33
|
+
[].tap do |acc|
|
|
34
|
+
while node && node.name != "html"
|
|
35
|
+
acc.unshift(node)
|
|
36
|
+
node = node.parent
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def path_segment(node)
|
|
42
|
+
node.name + id_fragment(node) + class_fragment(node)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def id_fragment(node)
|
|
46
|
+
(id = node["id"]) && !id.empty? ? "##{id}" : ""
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def class_fragment(node)
|
|
50
|
+
return "" if node["id"] && !node["id"].empty?
|
|
51
|
+
|
|
52
|
+
(klass = node["class"]&.split&.first) ? ".#{klass}" : ""
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ferrum"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require_relative "constants"
|
|
9
|
+
require_relative "server/command_dispatcher"
|
|
10
|
+
require_relative "server/idle_watcher"
|
|
11
|
+
|
|
12
|
+
module Browserctl
|
|
13
|
+
class Server
|
|
14
|
+
def initialize(headless: true)
|
|
15
|
+
prepare_runtime(headless)
|
|
16
|
+
@dispatcher = CommandDispatcher.new(@pages, @browser)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
write_pid
|
|
21
|
+
server, idle = setup_server
|
|
22
|
+
serve(server)
|
|
23
|
+
rescue SignalException
|
|
24
|
+
# clean shutdown
|
|
25
|
+
ensure
|
|
26
|
+
teardown(idle, server)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def prepare_runtime(headless)
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(SOCKET_PATH))
|
|
33
|
+
@browser = init_browser(headless)
|
|
34
|
+
init_state
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def init_browser(headless)
|
|
38
|
+
Ferrum::Browser.new(
|
|
39
|
+
headless: headless,
|
|
40
|
+
timeout: 30,
|
|
41
|
+
process_timeout: 30,
|
|
42
|
+
browser_options: { "no-sandbox" => nil, "disable-dev-shm-usage" => nil, "disable-gpu" => nil }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def init_state
|
|
47
|
+
@pages = {}
|
|
48
|
+
@last_used = Time.now
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def setup_server
|
|
53
|
+
server = setup_socket
|
|
54
|
+
idle = Thread.new { IdleWatcher.new(-> { @mutex.synchronize { @last_used } }).watch(server) }
|
|
55
|
+
[server, idle]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def setup_socket
|
|
59
|
+
FileUtils.rm_f(SOCKET_PATH)
|
|
60
|
+
server = UNIXServer.new(SOCKET_PATH)
|
|
61
|
+
File.chmod(0o600, SOCKET_PATH)
|
|
62
|
+
announce_socket
|
|
63
|
+
server
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def announce_socket
|
|
67
|
+
$stdout.puts "browserd listening on #{SOCKET_PATH}"
|
|
68
|
+
$stdout.flush
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def serve(server)
|
|
72
|
+
loop do
|
|
73
|
+
client = server.accept
|
|
74
|
+
Thread.new(client) { |c| handle(c) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle(socket)
|
|
79
|
+
if (line = socket.gets)
|
|
80
|
+
@mutex.synchronize { @last_used = Time.now }
|
|
81
|
+
socket.puts JSON.generate(process(line))
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
quietly { socket.puts JSON.generate({ error: e.message }) }
|
|
85
|
+
ensure
|
|
86
|
+
quietly { socket.close }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def process(line)
|
|
90
|
+
req = JSON.parse(line.chomp, symbolize_names: true)
|
|
91
|
+
@mutex.synchronize { @dispatcher.dispatch(req) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def teardown(idle, server)
|
|
95
|
+
idle&.kill
|
|
96
|
+
quietly { server&.close }
|
|
97
|
+
quietly { Timeout.timeout(5) { @browser.quit } }
|
|
98
|
+
quietly { File.unlink(SOCKET_PATH) }
|
|
99
|
+
quietly { File.unlink(PID_PATH) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_pid
|
|
103
|
+
File.write(PID_PATH, Process.pid.to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def quietly
|
|
107
|
+
yield
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class WorkflowError < StandardError; end
|
|
7
|
+
|
|
8
|
+
ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
|
|
9
|
+
StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class WorkflowContext
|
|
12
|
+
attr_reader :client
|
|
13
|
+
|
|
14
|
+
def initialize(params, client)
|
|
15
|
+
@params = params
|
|
16
|
+
@client = client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def method_missing(name, *args)
|
|
20
|
+
return @params[name] if @params.key?(name)
|
|
21
|
+
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(name, include_private = false)
|
|
26
|
+
@params.key?(name) || super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def page(name)
|
|
30
|
+
PageProxy.new(name.to_s, @client)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def invoke(workflow_name, **override_params)
|
|
34
|
+
name = workflow_name.to_s
|
|
35
|
+
guard_circular!(name)
|
|
36
|
+
track_invoke(name) { run_nested(workflow_name, **override_params) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assert(condition, msg = "assertion failed")
|
|
40
|
+
raise WorkflowError, msg unless condition
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def invoke_stack
|
|
46
|
+
@invoke_stack ||= []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def guard_circular!(name)
|
|
50
|
+
return unless invoke_stack.include?(name)
|
|
51
|
+
|
|
52
|
+
raise WorkflowError, "circular workflow invocation: #{(invoke_stack + [name]).join(' → ')}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def track_invoke(name)
|
|
56
|
+
invoke_stack << name
|
|
57
|
+
yield
|
|
58
|
+
ensure
|
|
59
|
+
invoke_stack.pop
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_nested(workflow_name, **override_params)
|
|
63
|
+
Runner.new.run_workflow(workflow_name, **@params, **override_params)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class PageProxy
|
|
68
|
+
def initialize(name, client)
|
|
69
|
+
@name = name
|
|
70
|
+
@client = client
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def goto(url) = unwrap @client.goto(@name, url)
|
|
74
|
+
def fill(sel, val) = unwrap @client.fill(@name, sel, val)
|
|
75
|
+
def click(sel) = unwrap @client.click(@name, sel)
|
|
76
|
+
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
77
|
+
def screenshot(**) = unwrap @client.screenshot(@name, **)
|
|
78
|
+
def wait_for(sel, timeout: 10) = unwrap @client.wait_for(@name, sel, timeout: timeout)
|
|
79
|
+
def url = @client.url(@name)[:url]
|
|
80
|
+
def evaluate(expr) = @client.evaluate(@name, expr)[:result]
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def unwrap(res)
|
|
85
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
86
|
+
|
|
87
|
+
res
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class WorkflowDefinition
|
|
92
|
+
attr_reader :name, :description, :param_defs, :steps
|
|
93
|
+
|
|
94
|
+
def initialize(name)
|
|
95
|
+
@name = name
|
|
96
|
+
@description = nil
|
|
97
|
+
@param_defs = {}
|
|
98
|
+
@steps = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def desc(text)
|
|
102
|
+
@description = text
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def param(name, required: false, secret: false, default: nil)
|
|
106
|
+
@param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def step(label, &block)
|
|
110
|
+
@steps << [label, block]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def call(params, client)
|
|
114
|
+
ctx = WorkflowContext.new(resolve_params(params), client)
|
|
115
|
+
execute_steps(ctx)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def execute_steps(ctx)
|
|
121
|
+
@steps.map { |label, block| run_step(ctx, label, block) }.each do |r|
|
|
122
|
+
raise WorkflowError, "step '#{r.name}' failed: #{r.error}" unless r.ok
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run_step(ctx, label, block)
|
|
127
|
+
ctx.instance_exec(&block)
|
|
128
|
+
StepResult.new(name: label, ok: true)
|
|
129
|
+
rescue WorkflowError, StandardError => e
|
|
130
|
+
StepResult.new(name: label, ok: false, error: e.message)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve_params(provided)
|
|
134
|
+
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
135
|
+
val = provided[name] || defn.default
|
|
136
|
+
raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
|
|
137
|
+
|
|
138
|
+
out[name] = val
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
REGISTRY = {} # rubocop:disable Style/MutableConstant
|
|
144
|
+
|
|
145
|
+
def self.workflow(name, &)
|
|
146
|
+
defn = WorkflowDefinition.new(name.to_s)
|
|
147
|
+
defn.instance_exec(&)
|
|
148
|
+
REGISTRY[name.to_s] = defn
|
|
149
|
+
end
|
|
150
|
+
end
|