browserctl 0.5.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +27 -32
  4. data/bin/browserctl +117 -108
  5. data/bin/browserd +9 -3
  6. data/examples/cloudflare_hitl.rb +5 -5
  7. data/examples/smoke/params_file.rb +3 -2
  8. data/examples/smoke/store_fetch.rb +5 -5
  9. data/examples/test_automation_practices/checkboxes.rb +39 -0
  10. data/examples/test_automation_practices/dynamic_elements.rb +40 -0
  11. data/examples/test_automation_practices/key_press.rb +41 -0
  12. data/examples/test_automation_practices/login.rb +34 -0
  13. data/examples/test_automation_practices/login_negative.rb +28 -0
  14. data/examples/test_automation_practices/notifications.rb +57 -0
  15. data/examples/the_internet/add_remove_elements.rb +1 -1
  16. data/examples/the_internet/checkboxes.rb +1 -1
  17. data/examples/the_internet/dropdown.rb +1 -1
  18. data/examples/the_internet/dynamic_loading.rb +2 -2
  19. data/examples/the_internet/login.rb +1 -1
  20. data/lib/browserctl/client.rb +101 -28
  21. data/lib/browserctl/commands/cookie.rb +59 -0
  22. data/lib/browserctl/commands/daemon.rb +77 -0
  23. data/lib/browserctl/commands/page.rb +47 -0
  24. data/lib/browserctl/commands/record.rb +1 -1
  25. data/lib/browserctl/commands/screenshot.rb +2 -2
  26. data/lib/browserctl/commands/session.rb +69 -0
  27. data/lib/browserctl/commands/snapshot.rb +2 -2
  28. data/lib/browserctl/commands/storage.rb +67 -0
  29. data/lib/browserctl/commands/workflow.rb +64 -0
  30. data/lib/browserctl/constants.rb +20 -1
  31. data/lib/browserctl/logger.rb +4 -4
  32. data/lib/browserctl/recording.rb +4 -4
  33. data/lib/browserctl/server/command_dispatcher.rb +22 -9
  34. data/lib/browserctl/server/handlers/cookies.rb +1 -1
  35. data/lib/browserctl/server/handlers/devtools.rb +1 -1
  36. data/lib/browserctl/server/handlers/hitl.rb +2 -1
  37. data/lib/browserctl/server/handlers/navigation.rb +24 -2
  38. data/lib/browserctl/server/handlers/observation.rb +0 -26
  39. data/lib/browserctl/server/handlers/page_lifecycle.rb +10 -3
  40. data/lib/browserctl/server/handlers/session.rb +93 -0
  41. data/lib/browserctl/server/handlers/storage.rb +109 -0
  42. data/lib/browserctl/server.rb +2 -2
  43. data/lib/browserctl/session.rb +79 -0
  44. data/lib/browserctl/version.rb +1 -1
  45. data/lib/browserctl/workflow.rb +38 -11
  46. metadata +19 -11
  47. data/lib/browserctl/commands/export_cookies.rb +0 -18
  48. data/lib/browserctl/commands/import_cookies.rb +0 -23
  49. data/lib/browserctl/commands/inspect.rb +0 -21
  50. data/lib/browserctl/commands/open_page.rb +0 -21
  51. data/lib/browserctl/commands/pause.rb +0 -22
  52. data/lib/browserctl/commands/status.rb +0 -30
  53. data/lib/browserctl/commands/watch.rb +0 -27
@@ -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
@@ -76,7 +76,7 @@ module Browserctl
76
76
  return unless pid.positive?
77
77
 
78
78
  Process.kill(0, pid)
79
- abort "browserd already running (PID #{pid}). Use 'browserctl shutdown' first."
79
+ abort "browserd already running (PID #{pid}). Use 'browserctl daemon stop' first."
80
80
  rescue Errno::ESRCH
81
81
  # Dead process — stale PID file, safe to continue
82
82
  rescue Errno::EPERM
@@ -125,7 +125,7 @@ module Browserctl
125
125
 
126
126
  def quietly
127
127
  yield
128
- rescue Exception # rubocop:disable Lint/RescueException
128
+ rescue Exception
129
129
  nil
130
130
  end
131
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -47,19 +47,37 @@ module Browserctl
47
47
  end
48
48
 
49
49
  def open_page(page_name, url: nil)
50
- res = @client.open_page(page_name.to_s, url: url)
50
+ res = @client.page_open(page_name.to_s, url: url)
51
51
  raise WorkflowError, res[:error] if res[:error]
52
52
 
53
53
  res
54
54
  end
55
55
 
56
56
  def close_page(page_name)
57
- res = @client.close_page(page_name.to_s)
57
+ res = @client.page_close(page_name.to_s)
58
58
  raise WorkflowError, res[:error] if res[:error]
59
59
 
60
60
  res
61
61
  end
62
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)
72
+ raise WorkflowError, res[:error] if res[:error]
73
+
74
+ res
75
+ end
76
+
77
+ def list_sessions
78
+ @client.session_list[:sessions]
79
+ end
80
+
63
81
  def invoke(workflow_name, **override_params)
64
82
  name = workflow_name.to_s
65
83
  guard_circular!(name)
@@ -100,15 +118,24 @@ module Browserctl
100
118
  @client = client
101
119
  end
102
120
 
103
- def goto(url) = unwrap @client.goto(@name, url)
104
- def fill(sel, val) = unwrap @client.fill(@name, sel, val)
105
- def click(sel) = unwrap @client.click(@name, sel)
106
- def snapshot(**) = unwrap @client.snapshot(@name, **)
107
- def screenshot(**) = unwrap @client.screenshot(@name, **)
108
- def watch(sel, timeout: 30) = unwrap @client.watch(@name, sel, timeout: timeout)
109
- def wait_for(sel, timeout: 10) = unwrap @client.wait_for(@name, sel, timeout: timeout)
110
- def url = @client.url(@name)[:url]
111
- def evaluate(expr) = @client.evaluate(@name, expr)[:result]
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
112
139
 
113
140
  private
114
141
 
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.5.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-25 00:00:00.000000000 Z
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,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '1.11'
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.11'
68
+ version: '2.1'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rake
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -141,6 +141,12 @@ files:
141
141
  - examples/cloudflare_hitl.rb
142
142
  - examples/smoke/params_file.rb
143
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
144
150
  - examples/the_internet/add_remove_elements.rb
145
151
  - examples/the_internet/checkboxes.rb
146
152
  - examples/the_internet/dropdown.rb
@@ -150,19 +156,18 @@ files:
150
156
  - lib/browserctl/client.rb
151
157
  - lib/browserctl/commands/cli_output.rb
152
158
  - lib/browserctl/commands/click.rb
153
- - lib/browserctl/commands/export_cookies.rb
159
+ - lib/browserctl/commands/cookie.rb
160
+ - lib/browserctl/commands/daemon.rb
154
161
  - lib/browserctl/commands/fill.rb
155
- - lib/browserctl/commands/import_cookies.rb
156
162
  - lib/browserctl/commands/init.rb
157
- - lib/browserctl/commands/inspect.rb
158
- - lib/browserctl/commands/open_page.rb
159
- - lib/browserctl/commands/pause.rb
163
+ - lib/browserctl/commands/page.rb
160
164
  - lib/browserctl/commands/record.rb
161
165
  - lib/browserctl/commands/resume.rb
162
166
  - lib/browserctl/commands/screenshot.rb
167
+ - lib/browserctl/commands/session.rb
163
168
  - lib/browserctl/commands/snapshot.rb
164
- - lib/browserctl/commands/status.rb
165
- - lib/browserctl/commands/watch.rb
169
+ - lib/browserctl/commands/storage.rb
170
+ - lib/browserctl/commands/workflow.rb
166
171
  - lib/browserctl/constants.rb
167
172
  - lib/browserctl/detectors.rb
168
173
  - lib/browserctl/errors.rb
@@ -179,9 +184,12 @@ files:
179
184
  - lib/browserctl/server/handlers/navigation.rb
180
185
  - lib/browserctl/server/handlers/observation.rb
181
186
  - lib/browserctl/server/handlers/page_lifecycle.rb
187
+ - lib/browserctl/server/handlers/session.rb
188
+ - lib/browserctl/server/handlers/storage.rb
182
189
  - lib/browserctl/server/idle_watcher.rb
183
190
  - lib/browserctl/server/page_session.rb
184
191
  - lib/browserctl/server/snapshot_builder.rb
192
+ - lib/browserctl/session.rb
185
193
  - lib/browserctl/version.rb
186
194
  - lib/browserctl/workflow.rb
187
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
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "optimist"
4
- require_relative "cli_output"
5
-
6
- module Browserctl
7
- module Commands
8
- class OpenPage
9
- extend CliOutput
10
-
11
- def self.run(client, args)
12
- opts = Optimist.options(args) do
13
- banner "Usage: browserctl open <page> [--url URL]"
14
- opt :url, "URL to navigate to", type: :string, short: "-u"
15
- end
16
- name = args.shift or abort "usage: browserctl open <page> [--url URL]"
17
- print_result(client.open_page(name, url: opts[:url]))
18
- end
19
- end
20
- end
21
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "cli_output"
4
-
5
- module Browserctl
6
- module Commands
7
- module Pause
8
- extend CliOutput
9
-
10
- def self.run(client, args)
11
- name = args.shift or abort "usage: browserctl pause <page>"
12
- res = client.pause(name)
13
- if res[:error]
14
- warn "Error: #{res[:error]}"
15
- exit 1
16
- end
17
- puts "Page '#{name}' paused. Browser is live — interact freely."
18
- puts "When done: browserctl resume #{name}"
19
- end
20
- end
21
- end
22
- end
@@ -1,30 +0,0 @@
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
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "optimist"
4
- require_relative "cli_output"
5
-
6
- module Browserctl
7
- module Commands
8
- class Watch
9
- extend CliOutput
10
-
11
- def self.run(client, args)
12
- opts = Optimist.options(args) do
13
- banner "Usage: browserctl watch <page> <selector> [--timeout N]"
14
- opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
15
- end
16
- name = args.shift
17
- selector = args.shift
18
- abort "usage: browserctl watch <page> <selector> [--timeout N]" unless name && selector
19
- unless opts[:timeout].positive?
20
- warn "Error: --timeout must be a positive number"
21
- exit 1
22
- end
23
- print_result(client.watch(name, selector, timeout: opts[:timeout]))
24
- end
25
- end
26
- end
27
- end