browserctl 0.2.1 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c12ad0cc3d34e7163bab1d678d65b320e946e0fa16fe7bd3e9f5881855d4801f
4
- data.tar.gz: 84d80f56270dfc7d252d7e63279f7c7c54123067837af457f5e92e82b2f0384e
3
+ metadata.gz: 1626673b3046c133aa7e1d63ee7d1886ed3071970227c184ab412a8f3cf48a4f
4
+ data.tar.gz: 4773002a9052247ec8afe259abd0d4c38cc3aa308be81bbeca756626bb206d44
5
5
  SHA512:
6
- metadata.gz: c95dbbbe2a573635421fe90cd65d2ccc41baf968c32bb65edcea52030e1b0080fd2af0f50ea9d85bc827ff4f537e2c5b4f3a7631c8242380a007df432e820906
7
- data.tar.gz: 1da50ab443d365e4a4b9ce145671e266fdfb0d83432b7b2313d87233b58b425252d2d9a365b0f8b3c31040961b90f684fe9299867c4cccfcd9600ea88cb93cc1
6
+ metadata.gz: 8e241d9fc064c419285b2e83b5e5154c6ccb639aeffb2179ace9b6ef3ec180069b76772cf5f6485af3cea4ef4616265a87dbbc43d7879cd0a71eec946e022ce8
7
+ data.tar.gz: b396184b74f6804e3b84105c4b75a89ef5a63751ad5577e2dc8008ed1c3111614f2d579ac9c3a12f7943968b3c4cab59fab69dfe392f4af51ba59fff0e8684ba
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1](https://github.com/patrick204nqh/browserctl/compare/v0.3.0...v0.3.1) (2026-04-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * allow pre-registered workflows with slash names (e.g. the_internet/login) ([e755a4a](https://github.com/patrick204nqh/browserctl/commit/e755a4aa20608fb11ce425c81ed8c0a43f3b13fe))
14
+ * remove unused client variable in runner spec ([94e1a31](https://github.com/patrick204nqh/browserctl/commit/94e1a310acc1d5ddb5e538860907040d3504f2c9))
15
+
16
+ ## [0.3.0](https://github.com/patrick204nqh/browserctl/compare/v0.2.2...v0.3.0) (2026-04-20)
17
+
18
+
19
+ ### Features
20
+
21
+ * add cookies, set_cookie, clear_cookies commands for CF clearance replay ([dd38f2d](https://github.com/patrick204nqh/browserctl/commit/dd38f2d587d12f789072326310a1f490e7f993b7))
22
+ * add cookies, set_cookie, clear_cookies commands for CF clearance replay ([c773fe2](https://github.com/patrick204nqh/browserctl/commit/c773fe25e73660cbe5c7e0e3d698fbf7709df51d))
23
+ * browserctl v0.3 — HITL, Cloudflare detection, init, compose, plugins, inspect, RBS, YARD ([ec24c85](https://github.com/patrick204nqh/browserctl/commit/ec24c85a7772b508c477ef4413b0b4a64be8dfa2))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * rubocop offenses — bump ClassLength max to 240, fix hash alignment ([52669fd](https://github.com/patrick204nqh/browserctl/commit/52669fd21051e1fef581d1c6acd3189d7d805dfb))
29
+ * rubocop offenses — move CLOUDFLARE_SIGNALS above private, autocorrect style ([0ad3d38](https://github.com/patrick204nqh/browserctl/commit/0ad3d38127671284c935809e189478c7160294f6))
30
+
31
+ ## [0.2.2](https://github.com/patrick204nqh/browserctl/compare/v0.2.1...v0.2.2) (2026-04-20)
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * never record plaintext fill values in session recordings ([a0ea9a1](https://github.com/patrick204nqh/browserctl/commit/a0ea9a15f4dbdc29a0a6aaa8186fa313e601c691))
37
+ * rescue Timeout::Error in quietly; remove duplicate [@last](https://github.com/last)_used update ([d0b1430](https://github.com/patrick204nqh/browserctl/commit/d0b143051a4a6adcdc80620388390bd649ee0bce))
38
+ * restrict screenshot path to ~/.browserctl/screenshots ([0a1303f](https://github.com/patrick204nqh/browserctl/commit/0a1303f95b1df3135a81c98e531ac31c99e2e275))
39
+ * rubocop offenses — constant scope and hash alignment ([cc11bc5](https://github.com/patrick204nqh/browserctl/commit/cc11bc546746c7f341573ffff43b02fdc94578d8))
40
+ * security patches and PageSession architecture refactor (review fixes) ([a01d0f7](https://github.com/patrick204nqh/browserctl/commit/a01d0f7bd3f58b51d21fc534a67dda25c15b029b))
41
+ * trigger release on tag push; revert release-please to default token ([011d4dc](https://github.com/patrick204nqh/browserctl/commit/011d4dcddd872f0ed88548ee9c2e8b19d64ad5c2))
42
+ * update CI workflows to use actions/checkout@v4 and set timeout for jobs ([15ab5d4](https://github.com/patrick204nqh/browserctl/commit/15ab5d471952ff9759eff7ff77ce2df30eea5e1f))
43
+ * validate workflow name to prevent path traversal ([1d0f221](https://github.com/patrick204nqh/browserctl/commit/1d0f221b35a855c1991bee5177b7e742ecb16e86))
44
+
8
45
  ## [0.2.1](https://github.com/patrick204nqh/browserctl/compare/v0.2.0...v0.2.1) (2026-04-20)
9
46
 
10
47
 
data/README.md CHANGED
@@ -138,6 +138,12 @@ browserctl shutdown
138
138
  | `shot <page> [--out PATH] [--full]` | Take a screenshot |
139
139
  | `url <page>` | Print current URL |
140
140
  | `eval <page> <expression>` | Evaluate a JS expression |
141
+ | `pause <page>` | Pause automation — browser stays live for manual interaction |
142
+ | `resume <page>` | Resume automation after manual action |
143
+ | `inspect <page>` | Open Chrome DevTools for a named page |
144
+ | `cookies <page>` | List all cookies as JSON |
145
+ | `set_cookie <page> <name> <value> <domain>` | Set a cookie (path defaults to `/`) |
146
+ | `clear_cookies <page>` | Clear all cookies for a page |
141
147
  | `record start <name>` | Begin recording commands as a replayable workflow |
142
148
  | `record stop [--out path]` | End recording; saves to `.browserctl/workflows/` or custom path |
143
149
  | `record status` | Show whether a recording is active |
@@ -262,7 +268,7 @@ browserctl run smoke_login --email me@example.com --password s3cr3t
262
268
 
263
269
  ### PageProxy methods
264
270
 
265
- `goto(url)` · `fill(selector, value)` · `click(selector)` · `snapshot(**opts)` · `screenshot(**opts)` · `wait_for(selector, timeout: 10)` · `url` · `evaluate(expression)`
271
+ `goto(url)` · `fill(selector, value)` · `click(selector)` · `snapshot(**opts)` · `screenshot(**opts)` · `wait_for(selector, timeout: 10)` · `url` · `evaluate(expression)` · `pause` · `resume` · `inspect_page` · `cookies` · `set_cookie(name, value, domain, path: "/")` · `clear_cookies`
266
272
 
267
273
  ---
268
274
 
data/bin/browserctl CHANGED
@@ -21,6 +21,9 @@ require "browserctl/commands/snapshot"
21
21
  require "browserctl/commands/screenshot"
22
22
  require "browserctl/commands/watch"
23
23
  require "browserctl/commands/record"
24
+ require "browserctl/commands/pause_resume"
25
+ require "browserctl/commands/init"
26
+ require "browserctl/commands/inspect"
24
27
 
25
28
  def print_result(res)
26
29
  if res.is_a?(Hash) && res[:error]
@@ -35,6 +38,9 @@ def usage
35
38
  puts <<~USAGE
36
39
  Usage: browserctl <command> [args]
37
40
 
41
+ Setup:
42
+ init Scaffold .browserctl/ in this project
43
+
38
44
  Browser commands (require browserd running):
39
45
  open <page> [--url URL] Open or focus a named page
40
46
  close <page> Close a named page
@@ -49,6 +55,12 @@ def usage
49
55
  url <page> Print current URL
50
56
  eval <page> <expression> Evaluate JS expression
51
57
  watch <page> <selector> [--timeout N] Wait for a selector to appear
58
+ pause <page> Pause automation — browser stays live
59
+ resume <page> Resume automation after manual action
60
+ inspect <page> Open Chrome DevTools for a named page
61
+ cookies <page> List all cookies as JSON
62
+ set_cookie <page> <name> <value> <domain> Set a cookie (path defaults to /)
63
+ clear_cookies <page> Clear all cookies for a page
52
64
 
53
65
  Recording commands:
54
66
  record start <name> Start recording browser commands
@@ -110,6 +122,7 @@ when "describe"
110
122
  puts JSON.pretty_generate(runner.describe_workflow(name))
111
123
 
112
124
  when "record" then Browserctl::Commands::Record.run(args)
125
+ when "init" then Browserctl::Commands::Init.run(args)
113
126
 
114
127
  else
115
128
  client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
@@ -126,6 +139,12 @@ else
126
139
  when "url" then print_result(client.url(args[0]))
127
140
  when "eval" then print_result(client.evaluate(args[0], args[1]))
128
141
  when "watch" then Browserctl::Commands::Watch.run(client, args)
142
+ when "pause" then Browserctl::Commands::PauseResume.pause(client, args)
143
+ when "resume" then Browserctl::Commands::PauseResume.resume(client, args)
144
+ when "inspect" then Browserctl::Commands::Inspect.run(client, args)
145
+ when "cookies" then print_result(client.cookies(args[0]))
146
+ when "set_cookie" then print_result(client.set_cookie(args[0], args[1], args[2], args[3]))
147
+ when "clear_cookies" then print_result(client.clear_cookies(args[0]))
129
148
  when "ping" then print_result(client.ping)
130
149
  when "shutdown" then print_result(client.shutdown)
131
150
  else
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Demonstrates the Cloudflare HITL (Human-in-the-Loop) pattern.
4
+ #
5
+ # When an agent navigates to a Cloudflare-protected page and hits a bot challenge,
6
+ # it cannot interact with the real page content. This workflow detects the challenge,
7
+ # pauses automation so a human can solve it in the live browser, then resumes.
8
+ #
9
+ # Run:
10
+ # browserctl run examples/cloudflare_hitl.rb --url https://example.com
11
+ #
12
+ # Note: modern Cloudflare often passes a real headed Chrome without challenge.
13
+ # The pause/resume branch fires only when the challenge page is actually served
14
+ # (typically against headless or CDP-fingerprinted browsers, or stricter zones).
15
+ # When paused, the terminal prints instructions. Solve the challenge in the open
16
+ # browser window, then run: browserctl resume main
17
+
18
+ Browserctl.workflow "cloudflare_hitl" do
19
+ desc "Navigate a Cloudflare-protected URL with human-assisted challenge bypass"
20
+
21
+ param :url, required: true
22
+ param :selector, default: "body"
23
+
24
+ step "open page" do
25
+ client.open_page("main")
26
+ end
27
+
28
+ step "navigate to target URL" do
29
+ res = client.goto("main", url)
30
+
31
+ if res[:challenge]
32
+ $stdout.puts ""
33
+ $stdout.puts " ⚠ Cloudflare challenge detected on #{url}"
34
+ $stdout.puts " → Pausing automation. Solve the challenge in the browser, then run:"
35
+ $stdout.puts " browserctl resume main"
36
+ $stdout.puts ""
37
+
38
+ client.pause("main")
39
+
40
+ # Block here until the human calls `browserctl resume main`.
41
+ # The pause command marks the page; resume unblocks and signals the CV.
42
+ # In a workflow context, we poll the server until the page is unpaused
43
+ # by attempting a lightweight snapshot — once it succeeds, we're through.
44
+ loop do
45
+ snap = client.snapshot("main", format: "html")
46
+ break unless snap[:challenge]
47
+
48
+ $stdout.puts " … still on challenge page, waiting 3s"
49
+ sleep 3
50
+ end
51
+
52
+ $stdout.puts " ✓ Challenge cleared — resuming automation"
53
+ end
54
+ end
55
+
56
+ step "wait for content and snapshot" do
57
+ page(:main).wait_for(selector, timeout: 15)
58
+ result = page(:main).snapshot(format: "ai")
59
+ $stdout.puts " Snapshot: #{result[:snapshot]&.length || 0} elements captured"
60
+ end
61
+
62
+ step "close page" do
63
+ client.close_page("main")
64
+ end
65
+ end
@@ -6,6 +6,7 @@ require_relative "constants"
6
6
  require_relative "recording"
7
7
 
8
8
  module Browserctl
9
+ # Thin IPC client that wraps each browserd command as a Ruby method call.
9
10
  class Client
10
11
  def initialize(socket_path = Browserctl.socket_path)
11
12
  @socket_path = socket_path
@@ -19,42 +20,138 @@ module Browserctl
19
20
  raise "browserd is not running — start it with: browserd"
20
21
  end
21
22
 
22
- # Convenience wrappers matching CLI command vocabulary
23
-
23
+ # Opens or focuses a named browser page.
24
+ # @param name [String] logical page name
25
+ # @param url [String, nil] optional URL to navigate to after opening
26
+ # @return [Hash] `{ ok: true, name: }` or `{ error: }`
24
27
  def open_page(name, url: nil) = call("open_page", name: name, url: url)
28
+
29
+ # Closes a named page and removes it from the session.
30
+ # @param name [String] logical page name
31
+ # @return [Hash] `{ ok: true }` or `{ error: }`
25
32
  def close_page(name) = call("close_page", name: name)
33
+
34
+ # Lists all open page names.
35
+ # @return [Hash] `{ pages: [String] }`
26
36
  def list_pages = call("list_pages")
37
+
38
+ # Navigates a page to a URL. Returns `challenge: true` when Cloudflare is detected.
39
+ # @param name [String] logical page name
40
+ # @param url [String] destination URL
41
+ # @return [Hash] `{ ok: true, url:, challenge: }` or `{ error: }`
27
42
  def goto(name, url) = call("goto", name: name, url: url)
28
43
 
44
+ # Clicks an element identified by CSS selector or snapshot ref.
45
+ # @param name [String] logical page name
46
+ # @param selector [String, nil] CSS selector
47
+ # @param ref [String, nil] snapshot ref (e.g. "e3")
48
+ # @return [Hash] `{ ok: true }` or `{ error: }`
29
49
  def click(name, selector = nil, ref: nil)
30
50
  raise ArgumentError, "click: provide selector or ref:" unless selector || ref
31
51
 
32
52
  call("click", name: name, selector: selector, ref: ref)
33
53
  end
34
54
 
55
+ # Fills an input element with a value.
56
+ # @param name [String] logical page name
57
+ # @param selector [String, nil] CSS selector
58
+ # @param value [String, nil] text to type
59
+ # @param ref [String, nil] snapshot ref
60
+ # @return [Hash] `{ ok: true }` or `{ error: }`
35
61
  def fill(name, selector = nil, value = nil, ref: nil)
36
62
  raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
37
63
 
38
64
  call("fill", name: name, selector: selector, ref: ref, value: value)
39
65
  end
40
66
 
67
+ # Takes a screenshot of a named page.
68
+ # @param name [String] logical page name
69
+ # @param path [String, nil] output path (default: ~/.browserctl/screenshots/)
70
+ # @param full [Boolean] capture full page (default: false)
71
+ # @return [Hash] `{ ok: true, path: }` or `{ error: }`
41
72
  def screenshot(name, path: nil, full: false) = call("screenshot", name: name, path: path, full: full)
42
73
 
74
+ # Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
75
+ # @param name [String] logical page name
76
+ # @param format [String] "ai" (token-efficient JSON) or "html" (raw HTML)
77
+ # @param diff [Boolean] return only elements changed since last snapshot
78
+ # @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
43
79
  def snapshot(name, format: "ai", diff: false)
44
80
  call("snapshot", name: name, format: format, diff: diff)
45
81
  end
46
82
 
83
+ # Waits for a CSS selector to appear (short timeout).
84
+ # @param name [String] logical page name
85
+ # @param selector [String] CSS selector to wait for
86
+ # @param timeout [Numeric] seconds before giving up (default: 10)
87
+ # @return [Hash] `{ ok: true }` or `{ error: }`
47
88
  def wait_for(name, selector, timeout: 10) = call("wait_for", name: name, selector: selector, timeout: timeout)
48
89
 
90
+ # Polls for a CSS selector with a longer timeout (suitable for async operations).
91
+ # @param name [String] logical page name
92
+ # @param selector [String] CSS selector to poll for
93
+ # @param timeout [Numeric] seconds before giving up (default: 30)
94
+ # @return [Hash] `{ ok: true, selector: }` or `{ error: }`
49
95
  def watch(name, selector, timeout: 30)
50
96
  call("watch", name: name, selector: selector, timeout: timeout)
51
97
  end
52
98
 
99
+ # Returns the current URL of a named page.
100
+ # @param name [String] logical page name
101
+ # @return [Hash] `{ ok: true, url: }` or `{ error: }`
53
102
  def url(name) = call("url", name: name)
103
+
104
+ # Evaluates a JavaScript expression and returns the result.
105
+ # @param name [String] logical page name
106
+ # @param expression [String] JavaScript expression
107
+ # @return [Hash] `{ ok: true, result: }` or `{ error: }`
54
108
  def evaluate(name, expression) = call("evaluate", name: name, expression: expression)
109
+
110
+ # Checks if browserd is alive.
111
+ # @return [Hash] `{ ok: true, pid: }` or raises if daemon is not running
55
112
  def ping = call("ping")
113
+
114
+ # Shuts down browserd gracefully.
115
+ # @return [Hash] `{ ok: true }`
56
116
  def shutdown = call("shutdown")
57
117
 
118
+ # Pauses automation on a page so a human can interact directly.
119
+ # @param name [String] logical page name
120
+ # @return [Hash] `{ ok: true, paused: true }` or `{ error: }`
121
+ def pause(name) = call("pause", name: name)
122
+
123
+ # Resumes automation on a paused page.
124
+ # @param name [String] logical page name
125
+ # @return [Hash] `{ ok: true, paused: false }` or `{ error: }`
126
+ def resume(name) = call("resume", name: name)
127
+
128
+ # Returns the Chrome DevTools URL for a named page.
129
+ # @param name [String] logical page name
130
+ # @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
131
+ def inspect_page(name) = call("inspect", name: name)
132
+
133
+ # Returns all cookies for a named page.
134
+ # @param name [String] logical page name
135
+ # @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
136
+ def cookies(name) = call("cookies", name: name)
137
+
138
+ # Sets a cookie on a named page.
139
+ # @param name [String] logical page name
140
+ # @param cookie_name [String] cookie name (e.g. "cf_clearance")
141
+ # @param value [String] cookie value
142
+ # @param domain [String] cookie domain (e.g. ".example.com")
143
+ # @param path [String] cookie path (default: "/")
144
+ # @return [Hash] `{ ok: true }` or `{ error: }`
145
+ def set_cookie(name, cookie_name, value, domain, path: "/")
146
+ call("set_cookie", name: name, cookie_name: cookie_name,
147
+ value: value, domain: domain, path: path)
148
+ end
149
+
150
+ # Clears all cookies for a named page.
151
+ # @param name [String] logical page name
152
+ # @return [Hash] `{ ok: true }` or `{ error: }`
153
+ def clear_cookies(name) = call("clear_cookies", name: name)
154
+
58
155
  private
59
156
 
60
157
  def communicate(payload)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ class Init
8
+ CONFIG_TEMPLATE = <<~YAML
9
+ # browserctl project configuration
10
+ # Uncomment and edit to customise.
11
+
12
+ # daemon: default # named daemon instance (see browserd --name)
13
+ # workflows_dir: .browserctl/workflows
14
+ YAML
15
+
16
+ def self.run(_args)
17
+ FileUtils.mkdir_p(".browserctl/workflows")
18
+ FileUtils.touch(".browserctl/workflows/.keep")
19
+
20
+ config_path = ".browserctl/config.yml"
21
+ File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
22
+
23
+ puts "Initialised browserctl project:"
24
+ puts " .browserctl/workflows/ (place workflow .rb files here)"
25
+ puts " .browserctl/config.yml (project settings)"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ module PauseResume
8
+ extend CliOutput
9
+
10
+ def self.pause(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
+
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
+ end
31
+ end
32
+ end
@@ -39,6 +39,8 @@ module Browserctl
39
39
  # ref-based interactions have no replayable selector — skip them
40
40
  return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
41
41
 
42
+ attrs = attrs.except(:value) if cmd.to_s == "fill"
43
+
42
44
  File.open(log_path(name), "a") do |f|
43
45
  f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
44
46
  end
@@ -90,7 +92,7 @@ module Browserctl
90
92
  ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
91
93
  when "fill"
92
94
  ["fill #{cmd[:selector]} on #{page}",
93
- "page(:#{page}).fill(#{cmd[:selector].inspect}, #{cmd[:value].inspect})"]
95
+ "page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
94
96
  when "click"
95
97
  ["click #{cmd[:selector]} on #{page}",
96
98
  "page(:#{page}).click(#{cmd[:selector].inspect})"]
@@ -10,6 +10,11 @@ module Browserctl
10
10
  File.expand_path("~/.browserctl/workflows")
11
11
  ].freeze
12
12
 
13
+ # Runs a named workflow with the given parameters.
14
+ # @param name [String] workflow name (must match /\A[a-zA-Z0-9_-]+\z/)
15
+ # @param params [Hash] keyword arguments passed to the workflow
16
+ # @return [Boolean] true if all steps succeeded
17
+ # @raise [WorkflowError] if the name is invalid or a step fails
13
18
  def run_workflow(name, **params)
14
19
  defn = fetch_workflow(name)
15
20
  results = defn.call(params, Client.new)
@@ -17,19 +22,35 @@ module Browserctl
17
22
  results.all?(&:ok)
18
23
  end
19
24
 
25
+ # Lists all registered workflows from the standard search paths.
26
+ # @return [Array<Hash>] array of `{ name:, desc: }` hashes
20
27
  def list_workflows
21
28
  load_all_workflows
22
29
  REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
23
30
  end
24
31
 
32
+ # Returns detailed information about a workflow.
33
+ # @param name [String] workflow name
34
+ # @return [Hash] `{ name:, desc:, params:, steps: }`
25
35
  def describe_workflow(name)
26
36
  defn = fetch_workflow(name)
27
37
  { name: defn.name, desc: defn.description, params: format_params(defn), steps: defn.steps.map(&:label) }
28
38
  end
29
39
 
40
+ SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
41
+
30
42
  private
31
43
 
44
+ def validate_name!(name)
45
+ return if SAFE_WORKFLOW_NAME.match?(name.to_s)
46
+
47
+ raise Browserctl::WorkflowError, "invalid workflow name: #{name.inspect} — use letters, digits, _ and - only"
48
+ end
49
+
32
50
  def fetch_workflow(name)
51
+ return REGISTRY[name.to_s] if REGISTRY.key?(name.to_s)
52
+
53
+ validate_name!(name)
33
54
  load_workflow_file(name)
34
55
  REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
35
56
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "snapshot_builder"
4
+ require_relative "page_session"
4
5
 
5
6
  module Browserctl
6
7
  class CommandDispatcher
@@ -18,68 +19,92 @@ module Browserctl
18
19
  "watch" => :cmd_watch,
19
20
  "url" => :cmd_url,
20
21
  "ping" => :cmd_ping,
21
- "shutdown" => :cmd_shutdown
22
+ "shutdown" => :cmd_shutdown,
23
+ "pause" => :cmd_pause,
24
+ "resume" => :cmd_resume,
25
+ "inspect" => :cmd_inspect,
26
+ "cookies" => :cmd_cookies,
27
+ "set_cookie" => :cmd_set_cookie,
28
+ "clear_cookies" => :cmd_clear_cookies
22
29
  }.freeze
23
30
 
24
- def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, mutex: Mutex.new)
25
- @pages = pages
26
- @browser = browser
27
- @snapshot = snapshot_builder
28
- @mutex = mutex
29
- @ref_registries = {}
30
- @prev_snapshots = {}
31
+ SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
32
+ SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
33
+ CLOUDFLARE_SIGNALS = [
34
+ "cf-challenge-running",
35
+ "cf_chl_opt",
36
+ "__cf_chl_f_tk",
37
+ "Just a moment..."
38
+ ].freeze
39
+
40
+ def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
41
+ @pages = pages
42
+ @browser = browser
43
+ @snapshot_builder = snapshot_builder
44
+ @global_mutex = global_mutex
31
45
  end
32
46
 
33
47
  def dispatch(req)
34
48
  handler = COMMAND_MAP[req[:cmd]]
35
- return { error: "unknown command: #{req[:cmd]}" } unless handler
49
+ if handler
50
+ Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
51
+ return send(handler, req)
52
+ end
53
+
54
+ if (plugin = Browserctl::PLUGIN_COMMANDS[req[:cmd]])
55
+ Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
56
+ session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
57
+ return plugin.call(session, req)
58
+ end
36
59
 
37
- Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
38
- send(handler, req)
60
+ { error: "unknown command: #{req[:cmd]}" }
39
61
  end
40
62
 
41
63
  private
42
64
 
43
65
  def cmd_open_page(req)
44
- page = @mutex.synchronize { @pages[req[:name]] ||= @browser.create_page }
45
- page.go_to(req[:url]) if req[:url]
66
+ session = @global_mutex.synchronize do
67
+ @pages[req[:name]] ||= PageSession.new(@browser.create_page)
68
+ end
69
+ session.page.go_to(req[:url]) if req[:url]
46
70
  { ok: true, name: req[:name] }
47
71
  end
48
72
 
49
73
  def cmd_close_page(req)
50
- @mutex.synchronize { @pages.delete(req[:name]) }&.close
74
+ session = @global_mutex.synchronize { @pages.delete(req[:name]) }
75
+ session&.page&.close
51
76
  { ok: true }
52
77
  end
53
78
 
54
79
  def cmd_list_pages(_req)
55
- { pages: @mutex.synchronize { @pages.keys } }
80
+ { pages: @global_mutex.synchronize { @pages.keys } }
56
81
  end
57
82
 
58
83
  def cmd_goto(req)
59
- with_page(req[:name]) do |p|
60
- p.go_to(req[:url])
61
- { ok: true, url: p.current_url }
84
+ with_page(req[:name]) do |session|
85
+ session.page.go_to(req[:url])
86
+ { ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
62
87
  end
63
88
  end
64
89
 
65
90
  def cmd_snapshot(req)
66
- with_page(req[:name]) { |p| take_snapshot(req[:name], p, req[:format], req[:diff]) }
91
+ with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
67
92
  end
68
93
 
69
- def take_snapshot(name, page, format, diff)
70
- return { ok: true, html: page.body } unless format == "ai"
94
+ def take_snapshot(session, format, diff)
95
+ challenge = cloudflare_challenge?(session.page)
96
+
97
+ return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
71
98
 
72
- snapshot = @snapshot.call(page)
99
+ snapshot = @snapshot_builder.call(session.page)
73
100
  registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
74
101
 
75
- result = @mutex.synchronize do
76
- prev = @prev_snapshots[name]
77
- @ref_registries[name] = registry
78
- @prev_snapshots[name] = snapshot
79
- diff && prev ? compute_diff(prev, snapshot) : snapshot
80
- end
102
+ prev = session.prev_snapshot
103
+ session.ref_registry = registry
104
+ session.prev_snapshot = snapshot
105
+ result = diff && prev ? compute_diff(prev, snapshot) : snapshot
81
106
 
82
- { ok: true, snapshot: result }
107
+ { ok: true, snapshot: result, challenge: challenge }
83
108
  end
84
109
 
85
110
  def compute_diff(prev, current)
@@ -91,14 +116,16 @@ module Browserctl
91
116
  end
92
117
 
93
118
  def cmd_evaluate(req)
94
- with_page(req[:name]) { |p| { ok: true, result: p.evaluate(req[:expression]) } }
119
+ with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
95
120
  end
96
121
 
97
122
  def cmd_fill(req)
98
- sel = resolve_selector(req[:name], req)
99
- return sel if sel.is_a?(Hash)
123
+ with_page(req[:name]) do |session|
124
+ sel = resolve_selector_from(session, req)
125
+ return sel if sel.is_a?(Hash)
100
126
 
101
- with_page(req[:name]) { |p| type_into(p, sel, req[:value]) }
127
+ type_into(session.page, sel, req[:value])
128
+ end
102
129
  end
103
130
 
104
131
  def type_into(page, selector, value)
@@ -111,10 +138,12 @@ module Browserctl
111
138
  end
112
139
 
113
140
  def cmd_click(req)
114
- sel = resolve_selector(req[:name], req)
115
- return sel if sel.is_a?(Hash)
141
+ with_page(req[:name]) do |session|
142
+ sel = resolve_selector_from(session, req)
143
+ return sel if sel.is_a?(Hash)
116
144
 
117
- with_page(req[:name]) { |p| click_element(p, sel) }
145
+ click_element(session.page, sel)
146
+ end
118
147
  end
119
148
 
120
149
  def click_element(page, selector)
@@ -126,56 +155,145 @@ module Browserctl
126
155
  end
127
156
 
128
157
  def cmd_screenshot(req)
129
- with_page(req[:name]) do |p|
130
- path = req[:path] || "/tmp/browserctl_shot_#{req[:name]}_#{Time.now.to_i}.png"
131
- p.screenshot(path: path, full: req.fetch(:full, false))
158
+ with_page(req[:name]) do |session|
159
+ path = safe_screenshot_path(req[:path], req[:name])
160
+ return path if path.is_a?(Hash)
161
+
162
+ FileUtils.mkdir_p(File.dirname(path))
163
+ session.page.screenshot(path: path, full: req.fetch(:full, false))
132
164
  { ok: true, path: path }
133
165
  end
134
166
  end
135
167
 
168
+ def safe_screenshot_path(requested, page_name)
169
+ if requested
170
+ expanded = File.expand_path(requested)
171
+ return { error: "path outside allowed directory (#{SCREENSHOT_DIR})" } \
172
+ unless expanded.start_with?(SCREENSHOT_DIR)
173
+ return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
174
+ unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
175
+
176
+ expanded
177
+ else
178
+ name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
+ File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
180
+ end
181
+ end
182
+
136
183
  def cmd_wait_for(req)
137
- with_page(req[:name]) { |p| wait_for_selector(p, req[:selector], req.fetch(:timeout, 10).to_f) }
184
+ with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
138
185
  end
139
186
 
140
187
  def cmd_watch(req)
141
- with_page(req[:name]) do |p|
142
- result = wait_for_selector(p, req[:selector], req.fetch(:timeout, 30).to_f)
188
+ with_page(req[:name]) do |session|
189
+ result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
143
190
  result[:error] ? result : { ok: true, selector: req[:selector] }
144
191
  end
145
192
  end
146
193
 
147
194
  def wait_for_selector(page, selector, timeout)
148
195
  deadline = Time.now + timeout
149
- sleep 0.2 until (found = page.at_css(selector)) || Time.now > deadline
150
- found ? { ok: true } : { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" }
196
+ loop do
197
+ found = page.at_css(selector)
198
+ break { ok: true } if found
199
+ break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
200
+
201
+ sleep 0.2
202
+ end
151
203
  end
152
204
 
153
205
  def cmd_url(req)
154
- with_page(req[:name]) { |p| { ok: true, url: p.current_url } }
206
+ with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
155
207
  end
156
208
 
157
- def cmd_ping(_req)
158
- { ok: true, pid: Process.pid }
209
+ def cmd_cookies(req)
210
+ session = @global_mutex.synchronize { @pages[req[:name]] }
211
+ return { error: "no page named '#{req[:name]}'" } unless session
212
+
213
+ all = session.page.cookies.all
214
+ { ok: true, cookies: all.values.map(&:to_h) }
215
+ end
216
+
217
+ def cmd_set_cookie(req)
218
+ session = @global_mutex.synchronize { @pages[req[:name]] }
219
+ return { error: "no page named '#{req[:name]}'" } unless session
220
+
221
+ session.page.cookies.set(
222
+ name: req[:cookie_name],
223
+ value: req[:value],
224
+ domain: req[:domain],
225
+ path: req.fetch(:path, "/")
226
+ )
227
+ { ok: true }
159
228
  end
160
229
 
230
+ def cmd_clear_cookies(req)
231
+ session = @global_mutex.synchronize { @pages[req[:name]] }
232
+ return { error: "no page named '#{req[:name]}'" } unless session
233
+
234
+ session.page.cookies.clear
235
+ { ok: true }
236
+ end
237
+
238
+ def cmd_inspect(req)
239
+ session = @global_mutex.synchronize { @pages[req[:name]] }
240
+ return { error: "no page named '#{req[:name]}'" } unless session
241
+
242
+ port = @browser.process.port
243
+ target_id = session.page.target_id
244
+ devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
245
+ "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
246
+ { ok: true, devtools_url: devtools_url }
247
+ end
248
+
249
+ def cmd_pause(req)
250
+ session = @global_mutex.synchronize { @pages[req[:name]] }
251
+ return { error: "no page named '#{req[:name]}'" } unless session
252
+
253
+ session.mutex.synchronize { session.pause! }
254
+ { ok: true, paused: true }
255
+ end
256
+
257
+ def cmd_resume(req)
258
+ session = @global_mutex.synchronize { @pages[req[:name]] }
259
+ return { error: "no page named '#{req[:name]}'" } unless session
260
+
261
+ session.mutex.synchronize do
262
+ session.resume!
263
+ session.pause_cv.signal
264
+ end
265
+ { ok: true, paused: false }
266
+ end
267
+
268
+ def cmd_ping(_req) = { ok: true, pid: Process.pid }
269
+
161
270
  def cmd_shutdown(_req)
162
271
  Process.kill("INT", Process.pid)
163
272
  { ok: true }
164
273
  end
165
274
 
166
275
  def with_page(name)
167
- page = @mutex.synchronize { @pages[name] }
168
- return { error: "no page named '#{name}'" } unless page
276
+ session = @global_mutex.synchronize { @pages[name] }
277
+ return { error: "no page named '#{name}'" } unless session
278
+
279
+ session.mutex.synchronize do
280
+ session.pause_cv.wait(session.mutex) while session.paused?
281
+ yield session
282
+ end
283
+ end
169
284
 
170
- yield page
285
+ def cloudflare_challenge?(page)
286
+ url = page.current_url.to_s
287
+ body = page.body.to_s
288
+ url.include?("challenge-platform") ||
289
+ CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
171
290
  end
172
291
 
173
- def resolve_selector(name, req)
292
+ def resolve_selector_from(session, req)
174
293
  return req[:selector] if req[:selector]
175
294
  return { error: "selector or ref required" } unless req[:ref]
176
295
 
177
- sel = @mutex.synchronize { @ref_registries.dig(name, req[:ref]) }
178
- sel || { error: "ref '#{req[:ref]}' not found — run snap first" }
296
+ session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
179
297
  end
180
298
  end
181
299
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class PageSession
5
+ attr_reader :page, :mutex, :pause_cv
6
+ attr_accessor :ref_registry, :prev_snapshot
7
+
8
+ def initialize(page)
9
+ @page = page
10
+ @mutex = Mutex.new
11
+ @pause_cv = ConditionVariable.new
12
+ @ref_registry = {}
13
+ @prev_snapshot = nil
14
+ @paused = false
15
+ end
16
+
17
+ def paused? = @paused
18
+ def pause! = (@paused = true)
19
+ def resume! = (@paused = false)
20
+ end
21
+ end
@@ -9,6 +9,7 @@ require_relative "constants"
9
9
  require_relative "logger"
10
10
  require_relative "server/command_dispatcher"
11
11
  require_relative "server/idle_watcher"
12
+ require_relative "server/page_session"
12
13
 
13
14
  module Browserctl
14
15
  class Server
@@ -16,7 +17,7 @@ module Browserctl
16
17
  @socket_path = socket_path
17
18
  @pid_path = pid_path
18
19
  prepare_runtime(headless)
19
- @dispatcher = CommandDispatcher.new(@pages, @browser, mutex: @mutex)
20
+ @dispatcher = CommandDispatcher.new(@pages, @browser, global_mutex: @mutex)
20
21
  end
21
22
 
22
23
  def run
@@ -85,7 +86,6 @@ module Browserctl
85
86
  def dispatch(socket, line)
86
87
  return unless line
87
88
 
88
- @mutex.synchronize { @last_used = Time.now }
89
89
  socket.puts JSON.generate(process(line))
90
90
  end
91
91
 
@@ -109,7 +109,7 @@ module Browserctl
109
109
 
110
110
  def quietly
111
111
  yield
112
- rescue StandardError
112
+ rescue Exception # rubocop:disable Lint/RescueException
113
113
  nil
114
114
  end
115
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -112,6 +112,13 @@ module Browserctl
112
112
  @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
113
113
  end
114
114
 
115
+ def compose(workflow_name)
116
+ source = REGISTRY[workflow_name.to_s]
117
+ raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
118
+
119
+ @steps.concat(source.steps)
120
+ end
121
+
115
122
  def call(params, client)
116
123
  ctx = WorkflowContext.new(resolve_params(params), client)
117
124
  execute_steps(ctx)
data/lib/browserctl.rb CHANGED
@@ -5,3 +5,11 @@ require_relative "browserctl/constants"
5
5
  require_relative "browserctl/workflow"
6
6
  require_relative "browserctl/runner"
7
7
  require_relative "browserctl/client"
8
+
9
+ module Browserctl
10
+ PLUGIN_COMMANDS = {} # rubocop:disable Style/MutableConstant
11
+
12
+ def self.register_command(name, &block)
13
+ PLUGIN_COMMANDS[name.to_s] = block
14
+ end
15
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '1.65'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
83
97
  description: Named browser sessions, Ruby workflow DSL, and a token-efficient DOM
84
98
  snapshot format. Built on Ferrum (Chrome DevTools Protocol).
85
99
  email:
@@ -96,6 +110,7 @@ files:
96
110
  - bin/browserctl
97
111
  - bin/browserd
98
112
  - bin/setup
113
+ - examples/cloudflare_hitl.rb
99
114
  - examples/the_internet/add_remove_elements.rb
100
115
  - examples/the_internet/checkboxes.rb
101
116
  - examples/the_internet/dropdown.rb
@@ -106,7 +121,10 @@ files:
106
121
  - lib/browserctl/commands/cli_output.rb
107
122
  - lib/browserctl/commands/click.rb
108
123
  - lib/browserctl/commands/fill.rb
124
+ - lib/browserctl/commands/init.rb
125
+ - lib/browserctl/commands/inspect.rb
109
126
  - lib/browserctl/commands/open_page.rb
127
+ - lib/browserctl/commands/pause_resume.rb
110
128
  - lib/browserctl/commands/record.rb
111
129
  - lib/browserctl/commands/screenshot.rb
112
130
  - lib/browserctl/commands/snapshot.rb
@@ -118,6 +136,7 @@ files:
118
136
  - lib/browserctl/server.rb
119
137
  - lib/browserctl/server/command_dispatcher.rb
120
138
  - lib/browserctl/server/idle_watcher.rb
139
+ - lib/browserctl/server/page_session.rb
121
140
  - lib/browserctl/server/snapshot_builder.rb
122
141
  - lib/browserctl/version.rb
123
142
  - lib/browserctl/workflow.rb