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.
@@ -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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Commands
5
+ module FlagExtractor
6
+ def self.extract_opt(args, flag)
7
+ i = args.index(flag)
8
+ return unless i
9
+
10
+ args.delete_at(i)
11
+ args.delete_at(i)
12
+ end
13
+ end
14
+ end
15
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ SOCKET_PATH = File.expand_path("~/.browserctl/browserd.sock")
5
+ PID_PATH = File.expand_path("~/.browserctl/browserd.pid")
6
+ IDLE_TTL = 30 * 60
7
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ VERSION = "0.1.0"
5
+ 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
data/lib/browserctl.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "browserctl/version"
4
+ require_relative "browserctl/constants"
5
+ require_relative "browserctl/workflow"
6
+ require_relative "browserctl/runner"
7
+ require_relative "browserctl/client"