browserctl 0.14.0 → 0.15.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +7 -3
  4. data/bin/browserctl +10 -2
  5. data/examples/tracing_otel.rb +46 -0
  6. data/lib/browserctl/callable_definition.rb +2 -2
  7. data/lib/browserctl/client.rb +41 -0
  8. data/lib/browserctl/commands/cookie.rb +17 -0
  9. data/lib/browserctl/commands/data.rb +73 -0
  10. data/lib/browserctl/commands/deprecation_notice.rb +33 -0
  11. data/lib/browserctl/commands/storage.rb +17 -0
  12. data/lib/browserctl/detectors/auth_required.rb +1 -1
  13. data/lib/browserctl/driver/cdp.rb +2 -2
  14. data/lib/browserctl/driver/ferrum_page_driver.rb +43 -0
  15. data/lib/browserctl/driver/page_driver.rb +90 -0
  16. data/lib/browserctl/error/codes.rb +15 -0
  17. data/lib/browserctl/error/exit_codes.rb +9 -1
  18. data/lib/browserctl/error/suggested_actions.rb +7 -0
  19. data/lib/browserctl/flow.rb +1 -1
  20. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +1 -1
  21. data/lib/browserctl/migrations.rb +2 -2
  22. data/lib/browserctl/replay/context.rb +1 -1
  23. data/lib/browserctl/replay/fingerprint_matcher.rb +1 -1
  24. data/lib/browserctl/server/command_dispatcher.rb +25 -17
  25. data/lib/browserctl/server/handlers/cookies.rb +18 -21
  26. data/lib/browserctl/server/handlers/data.rb +145 -0
  27. data/lib/browserctl/server/handlers/devtools.rb +8 -9
  28. data/lib/browserctl/server/handlers/hitl.rb +10 -0
  29. data/lib/browserctl/server/handlers/interaction.rb +21 -23
  30. data/lib/browserctl/server/handlers/navigation.rb +15 -15
  31. data/lib/browserctl/server/handlers/observation.rb +6 -6
  32. data/lib/browserctl/server/handlers/page_lifecycle.rb +12 -4
  33. data/lib/browserctl/server/handlers/state.rb +16 -6
  34. data/lib/browserctl/server/handlers/storage.rb +8 -8
  35. data/lib/browserctl/server/page_session.rb +21 -3
  36. data/lib/browserctl/server/plugin_dispatcher.rb +83 -0
  37. data/lib/browserctl/state/mutator.rb +1 -1
  38. data/lib/browserctl/tracing.rb +75 -0
  39. data/lib/browserctl/version.rb +1 -1
  40. data/lib/browserctl/workflow/page_proxy.rb +128 -0
  41. data/lib/browserctl/workflow.rb +3 -117
  42. data/lib/browserctl.rb +28 -2
  43. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08df95ac04f1480c4e39d857c1472342b8b256a2047cb83808ba62daaf184330'
4
- data.tar.gz: 1f67c6b991dd20af3907ab9a803f1021817e6d9b5dbe1774e42f0c509d907067
3
+ metadata.gz: 68f01c2bd0b65b0b6ac9243df7986b7d99c7d6a31b9ed346ae96da965b70588b
4
+ data.tar.gz: c1441ed479aad729c2af205313c35ea04793c1b6929a2a538584c907a982cff3
5
5
  SHA512:
6
- metadata.gz: 5752121da2c9a9551773b6ab8b9c4f060744f14a6f505d7c38ab9338a99b2cda4012a22869b633be883ad1c810bb717e0edb77da8ef4eb3b7bf8eef19593efde
7
- data.tar.gz: 4c44a6cfbb1b4402e30e1911c26bdfb608d3584ba463623fd8c75638ad836dcb428fcb0d99d58d37c2e557b0752c84f288288bad800667e18300cb36ace2570c
6
+ metadata.gz: 0ccabb955b4cfc4581d1afe8ea87b5d2afc432f0f31ef568a6aad2b9c13b37e28bb72b4ea74eef3056d5348aef4d1072059ab84a3721966d7cccb20bc9e69f1d
7
+ data.tar.gz: 2169db821391d766570667b3332672860db014e8ad3b35ab26e953127bd5fa9bba05f1b99c5d0195e01e988ee49409f197ccde4405981200384d7e60b67913b8
data/CHANGELOG.md CHANGED
@@ -10,6 +10,25 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.15.0](https://github.com/patrick204nqh/browserctl/compare/v0.14.0...v0.15.0) (2026-05-14)
14
+
15
+
16
+ ### ⚠ BREAKING CHANGES
17
+
18
+ * Cookie and storage wire / CLI / client surfaces are deprecated in favour of the unified `data --scope ...` verb. Old paths still work in v0.15 but emit a one-line deprecation warning to stderr (suppressed under `--output json`) and are removed at 1.0. See docs/architecture/decisions/0021-data-verb-consolidation.md.
19
+
20
+ ### Features
21
+
22
+ * data verb consolidates cookie and storage commands (v0.15 WS-1 PR 2) ([#207](https://github.com/patrick204nqh/browserctl/issues/207)) ([4b0beca](https://github.com/patrick204nqh/browserctl/commit/4b0becaf014fe27f92dfdc48f03bd1d75e06896d))
23
+ * pluggable tracing hook (v0.15 WS-4 PR 7) ([#203](https://github.com/patrick204nqh/browserctl/issues/203)) ([f8f725e](https://github.com/patrick204nqh/browserctl/commit/f8f725e4022defa2c56e1c3dfda831f348bf2fc4))
24
+ * plugin command timeout and error isolation (v0.15 WS-2 PR 5) ([#202](https://github.com/patrick204nqh/browserctl/issues/202)) ([5bc1e36](https://github.com/patrick204nqh/browserctl/commit/5bc1e3609a1d0739369cdad7e5c5cd27f6116ec7))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * dialog handlers use with_page mutex pattern (v0.15 WS-2 PR 4) ([#193](https://github.com/patrick204nqh/browserctl/issues/193)) ([f35f513](https://github.com/patrick204nqh/browserctl/commit/f35f5137011db40b52a77008bba62e19e73ccb0d))
30
+ * sweep remaining [@pages](https://github.com/pages) raw access in handlers (v0.15 WS-2 PR 4b) ([#208](https://github.com/patrick204nqh/browserctl/issues/208)) ([9d57a6d](https://github.com/patrick204nqh/browserctl/commit/9d57a6d2bb0a20a06c3a04024517448cd37c290a))
31
+
13
32
  ## [0.14.0](https://github.com/patrick204nqh/browserctl/compare/v0.13.1...v0.14.0) (2026-05-11)
14
33
 
15
34
 
data/README.md CHANGED
@@ -5,7 +5,11 @@
5
5
  <h1 align="center">browserctl</h1>
6
6
 
7
7
  <p align="center">
8
- The browser you delegate to your agents — with a pause button for the parts that still need you.
8
+ persistent browser sessions with human-in-the-loop.
9
+ </p>
10
+
11
+ <p align="center">
12
+ <sub>Built for AI agents. Useful to the engineers and QA folks who work with them.</sub>
9
13
  </p>
10
14
 
11
15
  <p align="center">
@@ -55,8 +59,8 @@ browserctl url main
55
59
  browserctl page snapshot main --diff # only what changed
56
60
 
57
61
  # Session persistence: save now, pick up later
58
- browserctl session save my-session
59
- # On a fresh daemon tomorrow: `browserctl session load my-session`
62
+ browserctl state save my-session
63
+ # On a fresh daemon tomorrow: `browserctl state load my-session`
60
64
  # → tabs restored, cookies intact, no re-login needed
61
65
 
62
66
  # 7. Done
data/bin/browserctl CHANGED
@@ -25,6 +25,7 @@ require "browserctl/commands/init"
25
25
  require "browserctl/commands/page"
26
26
  require "browserctl/commands/cookie"
27
27
  require "browserctl/commands/storage"
28
+ require "browserctl/commands/data"
28
29
  require "browserctl/commands/state"
29
30
  require "browserctl/commands/daemon"
30
31
  require "browserctl/commands/workflow"
@@ -93,14 +94,20 @@ def usage
93
94
  dialog accept <page> [text]
94
95
  dialog dismiss <page>
95
96
 
96
- Cookie:
97
+ Data (v0.15+, replaces 'cookie *' and 'storage *' — removed at 1.0):
98
+ data get <page> <key> --scope {cookies|localStorage|sessionStorage}
99
+ data set <page> <key> <value> --scope SCOPE [--domain D] [--path /]
100
+ data delete <page> --scope SCOPE
101
+ data list <page> --scope SCOPE
102
+
103
+ Cookie (deprecated, removed at 1.0 — use 'data --scope cookies'):
97
104
  cookie list <page>
98
105
  cookie set <page> <name> <value> --domain DOMAIN [--path /]
99
106
  cookie delete <page>
100
107
  cookie export <page> <path>
101
108
  cookie import <page> <path>
102
109
 
103
- Storage:
110
+ Storage (deprecated, removed at 1.0 — use 'data --scope localStorage|sessionStorage'):
104
111
  storage get <page> <key> [--store local|session]
105
112
  storage set <page> <key> <value> [--store local|session]
106
113
  storage export <page> <path> [--store local|session|all]
@@ -214,6 +221,7 @@ begin
214
221
  when "page" then Browserctl::Commands::Page.run(client, args)
215
222
  when "cookie" then Browserctl::Commands::Cookie.run(client, args)
216
223
  when "storage" then Browserctl::Commands::Storage.run(client, args)
224
+ when "data" then Browserctl::Commands::Data.run(client, args)
217
225
  when "state" then Browserctl::Commands::State.run(client, args)
218
226
  when "daemon" then Browserctl::Commands::Daemon.run(client, args)
219
227
  when "auth-check"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: wire browserctl's tracing seam to an OpenTelemetry exporter.
4
+ #
5
+ # This file is doc-only — it is NOT required by the gem, and OpenTelemetry
6
+ # is NOT a runtime dependency. Add `opentelemetry-sdk` and an exporter to
7
+ # your own application's Gemfile if you want this wiring.
8
+ #
9
+ # Usage (from a host app, before any browserctl commands are dispatched):
10
+ #
11
+ # require "opentelemetry/sdk"
12
+ # require "opentelemetry/exporter/otlp"
13
+ # require "browserctl"
14
+ # require_relative "path/to/tracing_otel"
15
+ #
16
+ # Browserctl::Tracing.backend = OtelTracingBackend.new
17
+ #
18
+ # Every command dispatched by the daemon will then emit a span named
19
+ # `command.<cmd>` with `command`, `page`, and `duration_ms` attributes.
20
+
21
+ require "opentelemetry/sdk"
22
+
23
+ # Adapter from browserctl's Backend contract to the OpenTelemetry Ruby API.
24
+ class OtelTracingBackend
25
+ def initialize(tracer_name: "browserctl", version: Browserctl::VERSION)
26
+ @tracer = OpenTelemetry.tracer_provider.tracer(tracer_name, version)
27
+ end
28
+
29
+ def start_span(name, attributes: {})
30
+ @tracer.start_span(name, attributes: stringify(attributes))
31
+ end
32
+
33
+ def end_span(span, status:, attributes: {})
34
+ return if span.nil?
35
+
36
+ attributes.each { |k, v| span.set_attribute(k.to_s, v) unless v.nil? }
37
+ span.status = OpenTelemetry::Trace::Status.error if status == :error
38
+ span.finish
39
+ end
40
+
41
+ private
42
+
43
+ def stringify(attrs)
44
+ attrs.each_with_object({}) { |(k, v), h| h[k.to_s] = v unless v.nil? }
45
+ end
46
+ end
@@ -20,8 +20,8 @@ module Browserctl
20
20
  # deliberately absent from `FlowContext` — flows return state, workflows
21
21
  # share state.
22
22
  class CallableDefinition
23
- ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
24
- StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
23
+ ParamDef = Data.define(:name, :required, :secret, :default, :secret_ref)
24
+ StepDef = Data.define(:label, :block, :retry_count, :timeout)
25
25
 
26
26
  attr_reader :name, :description, :param_defs, :steps
27
27
 
@@ -245,6 +245,47 @@ module Browserctl
245
245
  call("storage_delete", name: name, stores: stores)
246
246
  end
247
247
 
248
+ # Reads a single key from the given browser-side scope.
249
+ # Introduced in v0.15 as the unified replacement for `storage_get` /
250
+ # `cookies` (see ADR-0021).
251
+ # @param name [String] logical page name
252
+ # @param key [String] storage key (n/a for `scope: "cookies"`; use {#data_list})
253
+ # @param scope [String] one of "cookies", "localStorage", "sessionStorage"
254
+ # @return [Hash] `{ ok: true, scope:, key:, value: }` or `{ error:, code: }`
255
+ def data_get(name, key, scope:)
256
+ call("data_get", name: name, key: key, scope: scope)
257
+ end
258
+
259
+ # Writes a single key/value into the given browser-side scope.
260
+ # For `scope: "cookies"`, `domain:` is required and `path:` defaults to "/".
261
+ # @param name [String] logical page name
262
+ # @param key [String] storage key (cookie name when scope is cookies)
263
+ # @param value [String] value to store
264
+ # @param scope [String] one of "cookies", "localStorage", "sessionStorage"
265
+ # @param domain [String, nil] cookie domain (required when scope is cookies)
266
+ # @param path [String] cookie path (default: "/")
267
+ # @return [Hash] `{ ok: true, scope:, key: }` or `{ error:, code: }`
268
+ def data_set(name, key, value, scope:, domain: nil, path: "/") # rubocop:disable Metrics/ParameterLists
269
+ call("data_set", name: name, key: key, value: value,
270
+ scope: scope, domain: domain, path: path)
271
+ end
272
+
273
+ # Clears every entry in the given browser-side scope.
274
+ # @param name [String] logical page name
275
+ # @param scope [String] one of "cookies", "localStorage", "sessionStorage"
276
+ # @return [Hash] `{ ok: true, scope:, deleted: N }` or `{ error:, code: }`
277
+ def data_delete(name, scope:)
278
+ call("data_delete", name: name, scope: scope)
279
+ end
280
+
281
+ # Lists every entry in the given browser-side scope.
282
+ # @param name [String] logical page name
283
+ # @param scope [String] one of "cookies", "localStorage", "sessionStorage"
284
+ # @return [Hash] `{ ok: true, scope:, entries: [...], count: N }` or `{ error:, code: }`
285
+ def data_list(name, scope:)
286
+ call("data_list", name: name, scope: scope)
287
+ end
288
+
248
289
  # Fires a keydown + keyup event for the given key name on a page.
249
290
  # @param name [String] logical page name
250
291
  # @param key [String] key name e.g. "Enter", "Tab", "Escape", "ArrowDown"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cli_output"
4
+ require_relative "deprecation_notice"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
@@ -11,6 +12,11 @@ module Browserctl
11
12
 
12
13
  def self.run(client, args)
13
14
  sub = args.shift or abort USAGE
15
+ # Deprecated in v0.15 — see ADR-0021. Removed at 1.0.
16
+ DeprecationNotice.emit(
17
+ "cookie #{sub}",
18
+ deprecation_replacement(sub)
19
+ )
14
20
  case sub
15
21
  when "list" then run_list(client, args)
16
22
  when "set" then run_set(client, args)
@@ -21,6 +27,17 @@ module Browserctl
21
27
  end
22
28
  end
23
29
 
30
+ def self.deprecation_replacement(sub)
31
+ case sub
32
+ when "list" then "data list <page> --scope cookies"
33
+ when "set" then "data set <page> <name> <value> --scope cookies --domain DOMAIN"
34
+ when "delete" then "data delete <page> --scope cookies"
35
+ when "export" then "data list <page> --scope cookies # write to file client-side"
36
+ when "import" then "data set <page> --scope cookies"
37
+ else "data <op> --scope cookies"
38
+ end
39
+ end
40
+
24
41
  def self.run_list(client, args)
25
42
  page = args.shift or abort "usage: browserctl cookie list <page>"
26
43
  print_result(client.cookies(page))
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_output"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ # `browserctl data <op> --scope <scope>` — unified verb for browser-side
8
+ # persistent data. Introduced in v0.15 (ADR-0021) as the replacement for
9
+ # the duplicated `cookie *` and `storage *` families.
10
+ module Data
11
+ extend CliOutput
12
+
13
+ USAGE = "Usage: browserctl data <get|set|delete|list> --scope " \
14
+ "{cookies|localStorage|sessionStorage} [args]"
15
+
16
+ def self.run(client, args)
17
+ sub = args.shift or abort USAGE
18
+ case sub
19
+ when "get" then run_get(client, args)
20
+ when "set" then run_set(client, args)
21
+ when "delete" then run_delete(client, args)
22
+ when "list" then run_list(client, args)
23
+ else abort "unknown data subcommand '#{sub}'\n#{USAGE}"
24
+ end
25
+ end
26
+
27
+ def self.run_get(client, args)
28
+ page = args.shift or abort "usage: browserctl data get <page> <key> --scope SCOPE"
29
+ key = args.shift or abort "usage: browserctl data get <page> <key> --scope SCOPE"
30
+ scope = extract_required_scope(args)
31
+ print_result(client.data_get(page, key, scope: scope))
32
+ end
33
+
34
+ SET_USAGE = "usage: browserctl data set <page> <key> <value> --scope SCOPE [--domain D] [--path /]"
35
+
36
+ def self.run_set(client, args)
37
+ page = args.shift or abort SET_USAGE
38
+ key = args.shift or abort SET_USAGE
39
+ value = args.shift or abort SET_USAGE
40
+ scope = extract_required_scope(args)
41
+ domain = extract_opt(args, "--domain")
42
+ path = extract_opt(args, "--path") || "/"
43
+ print_result(client.data_set(page, key, value, scope: scope, domain: domain, path: path))
44
+ end
45
+
46
+ def self.run_delete(client, args)
47
+ page = args.shift or abort "usage: browserctl data delete <page> --scope SCOPE"
48
+ scope = extract_required_scope(args)
49
+ print_result(client.data_delete(page, scope: scope))
50
+ end
51
+
52
+ def self.run_list(client, args)
53
+ page = args.shift or abort "usage: browserctl data list <page> --scope SCOPE"
54
+ scope = extract_required_scope(args)
55
+ print_result(client.data_list(page, scope: scope))
56
+ end
57
+
58
+ def self.extract_required_scope(args)
59
+ scope = extract_opt(args, "--scope")
60
+ abort "missing required flag: --scope {cookies|localStorage|sessionStorage}" unless scope
61
+ scope
62
+ end
63
+
64
+ def self.extract_opt(args, flag)
65
+ idx = args.index(flag)
66
+ return nil unless idx
67
+
68
+ args.delete_at(idx)
69
+ args.delete_at(idx)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "output_format"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ # One-shot deprecation warning emitter for v0.15 alias verbs.
8
+ #
9
+ # Per ADR-0021: when a CLI invocation uses `cookie *` or `storage *`,
10
+ # we emit exactly one line to stderr — never under `--output json`, so
11
+ # JSON consumers (AI agents, scripts) keep a clean parser input. The
12
+ # warning is memoised process-wide so a single CLI invocation only ever
13
+ # prints it once even if a wrapper calls the dispatcher multiple times.
14
+ module DeprecationNotice
15
+ REMOVAL = "Removed at 1.0."
16
+
17
+ module_function
18
+
19
+ def emit(old_verb, replacement, io: $stderr)
20
+ return if @emitted
21
+ return if OutputFormat.current.json?
22
+
23
+ @emitted = true
24
+ io.puts "warning: '#{old_verb}' is deprecated; use '#{replacement}'. #{REMOVAL}"
25
+ end
26
+
27
+ # Test-only — reset memoisation between examples.
28
+ def reset!
29
+ @emitted = false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cli_output"
4
+ require_relative "deprecation_notice"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
@@ -11,6 +12,11 @@ module Browserctl
11
12
 
12
13
  def self.run(client, args)
13
14
  sub = args.shift or abort USAGE
15
+ # Deprecated in v0.15 — see ADR-0021. Removed at 1.0.
16
+ DeprecationNotice.emit(
17
+ "storage #{sub}",
18
+ deprecation_replacement(sub)
19
+ )
14
20
  case sub
15
21
  when "get" then run_get(client, args)
16
22
  when "set" then run_set(client, args)
@@ -21,6 +27,17 @@ module Browserctl
21
27
  end
22
28
  end
23
29
 
30
+ def self.deprecation_replacement(sub)
31
+ case sub
32
+ when "get" then "data get <page> <key> --scope localStorage"
33
+ when "set" then "data set <page> <key> <value> --scope localStorage"
34
+ when "delete" then "data delete <page> --scope localStorage"
35
+ when "export" then "data list <page> --scope localStorage # write to file client-side"
36
+ when "import" then "data set <page> --scope localStorage"
37
+ else "data <op> --scope localStorage"
38
+ end
39
+ end
40
+
24
41
  def self.run_get(client, args)
25
42
  page = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
26
43
  key = args.shift or abort "usage: browserctl storage get <page> <key> [--store local|session]"
@@ -21,7 +21,7 @@ module Browserctl
21
21
  # runs server-side (handlers/observation.rb) and client-side (workflow
22
22
  # `load_state` hook).
23
23
  module AuthRequired
24
- Result = Struct.new(:triggered, :code, :reason, :suggested_flow, keyword_init: true) do
24
+ Result = Data.define(:triggered, :code, :reason, :suggested_flow) do
25
25
  def to_h
26
26
  { triggered: triggered, code: code, reason: reason, suggested_flow: suggested_flow }.compact
27
27
  end
@@ -45,9 +45,9 @@ module Browserctl
45
45
  capability == :devtools
46
46
  end
47
47
 
48
- def devtools_info(page)
48
+ def devtools_info(page_driver)
49
49
  port = @ferrum.process.port
50
- target_id = page.target_id
50
+ target_id = page_driver.target_id
51
51
  { port: port, target_id: target_id }
52
52
  end
53
53
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "page_driver"
4
+
5
+ module Browserctl
6
+ module Driver
7
+ # The only {PageDriver} implementation v0.15 ships. Wraps a Ferrum page
8
+ # (or {CDPPage} delegator over one) and forwards each interface method to
9
+ # the underlying Ferrum API. Intentionally dumb — no caching, no policy,
10
+ # no logging. Anything beyond plain forwarding belongs in the handler or
11
+ # in a dedicated wrapper, not here.
12
+ class FerrumPageDriver
13
+ include PageDriver
14
+
15
+ # @return [Object] the underlying Ferrum/CDP page. Exposed for callers
16
+ # that still bridge through Ferrum-typed APIs (currently only the CDP
17
+ # driver's `devtools_info`). New handler code must not use this.
18
+ attr_reader :raw_page
19
+
20
+ def initialize(page)
21
+ @raw_page = page
22
+ end
23
+
24
+ def go_to(url) = @raw_page.go_to(url)
25
+ def current_url = @raw_page.current_url
26
+ def body = @raw_page.body
27
+ def evaluate(expression) = @raw_page.evaluate(expression)
28
+ def at_css(selector) = @raw_page.at_css(selector)
29
+ def screenshot(path:, full: false) = @raw_page.screenshot(path: path, full: full)
30
+ def activate = @raw_page.activate
31
+ def close = @raw_page.close
32
+ def on(event, &) = @raw_page.on(event, &)
33
+ def off(event, id) = @raw_page.off(event, id)
34
+ def keyboard_down(key) = @raw_page.keyboard.down(key)
35
+ def keyboard_up(key) = @raw_page.keyboard.up(key)
36
+ def mouse_move(x:, y:) = @raw_page.mouse.move(x: x, y: y) # rubocop:disable Naming/MethodParameterName
37
+ def cookies_all = @raw_page.cookies.all
38
+ def cookies_set(**) = @raw_page.cookies.set(**)
39
+ def cookies_clear = @raw_page.cookies.clear
40
+ def target_id = @raw_page.target_id
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Driver
5
+ # PageDriver is the interface every handler in `lib/browserctl/server/handlers/`
6
+ # talks to instead of touching a raw Ferrum page. It exists so unit tests can
7
+ # swap in a `FakePageDriver` (see `spec/support/fake_page_driver.rb`) without
8
+ # spawning a real browser.
9
+ #
10
+ # This module is intentionally a thin contract: Ruby has no real interfaces,
11
+ # so the implementation is duck-typed. The method list below is the entire
12
+ # public surface handlers may use. New entries here require a corresponding
13
+ # implementation in {FerrumPageDriver} **and** the test double, plus a
14
+ # handler that justifies them — do not add methods speculatively.
15
+ #
16
+ # Element-returning methods (currently only {#at_css}) return whatever the
17
+ # underlying driver returns. The fake driver returns stub objects with
18
+ # matching duck-typed methods (`focus`, `type`, `click`, `evaluate`,
19
+ # `select_file`). Handlers should treat these as opaque element handles.
20
+ #
21
+ # PageDriver lives in the **Extension** zone of the public surface
22
+ # (see `docs/reference/api-stability.md`). It is a testing seam, not an
23
+ # invitation to ship a non-Ferrum backend — that path is explicitly a
24
+ # non-goal of v0.15 (`docs/plans/v0.15-lock.md`).
25
+ module PageDriver
26
+ # Navigate the page to the given URL. Blocks until load completes.
27
+ # @param url [String]
28
+ def go_to(url) = raise NotImplementedError
29
+
30
+ # @return [String] the current top-frame URL
31
+ def current_url = raise NotImplementedError
32
+
33
+ # @return [String] the current page body HTML
34
+ def body = raise NotImplementedError
35
+
36
+ # Evaluate a JS expression in the page context.
37
+ # @param expression [String]
38
+ # @return [Object] the JSON-decoded result
39
+ def evaluate(expression) = raise NotImplementedError
40
+
41
+ # Find the first element matching the CSS selector.
42
+ # @param selector [String]
43
+ # @return [Object, nil] an element handle (duck-typed: `focus`, `type`,
44
+ # `click`, `evaluate`, `select_file`) or nil if no match
45
+ def at_css(selector) = raise NotImplementedError
46
+
47
+ # Capture a screenshot to disk.
48
+ # @param path [String]
49
+ # @param full [Boolean]
50
+ def screenshot(path:, full: false) = raise NotImplementedError
51
+
52
+ # Bring the page tab to the foreground (headed mode only).
53
+ def activate = raise NotImplementedError
54
+
55
+ # Close the underlying page/tab.
56
+ def close = raise NotImplementedError
57
+
58
+ # Subscribe to a page event (currently only `:dialog`).
59
+ # @return [Object] a subscription id usable with {#off}
60
+ def on(event, &) = raise NotImplementedError
61
+
62
+ # Remove a subscription created by {#on}.
63
+ def off(event, id) = raise NotImplementedError
64
+
65
+ # Press a key down. Pair with {#keyboard_up}.
66
+ def keyboard_down(key) = raise NotImplementedError
67
+
68
+ # Release a key.
69
+ def keyboard_up(key) = raise NotImplementedError
70
+
71
+ # Move the mouse pointer to viewport coordinates.
72
+ def mouse_move(x:, y:) = raise NotImplementedError # rubocop:disable Naming/MethodParameterName
73
+
74
+ # @return [Hash{String => Object}] all cookies for the page, keyed by
75
+ # cookie name, with each value responding to `to_h`.
76
+ def cookies_all = raise NotImplementedError
77
+
78
+ # Set a cookie. Accepts `name:`, `value:`, `domain:`, `path:`, and any
79
+ # of `httponly:`, `secure:`, `expires:`.
80
+ def cookies_set(**) = raise NotImplementedError
81
+
82
+ # Clear every cookie on the page.
83
+ def cookies_clear = raise NotImplementedError
84
+
85
+ # Underlying CDP target id; used by the devtools handler. Backends that
86
+ # do not expose CDP should raise.
87
+ def target_id = raise NotImplementedError
88
+ end
89
+ end
90
+ end
@@ -29,6 +29,18 @@ module Browserctl
29
29
  INVALID_STATE_NAME = "INVALID_STATE_NAME"
30
30
  INVALID_DSL_USAGE = "INVALID_DSL_USAGE"
31
31
  INVALID_FORMAT_VERSION = "INVALID_FORMAT_VERSION"
32
+ INVALID_ARGUMENT = "INVALID_ARGUMENT"
33
+
34
+ # Plugin family — introduced in v0.15 WS-2 PR 5 to isolate the daemon
35
+ # from misbehaving third-party commands registered via
36
+ # `Browserctl.register_command`. PLUGIN_FAILED is the catch-all when an
37
+ # uncaught exception escapes the plugin block; PLUGIN_TIMED_OUT is
38
+ # emitted when the per-plugin timeout (default 30s, configurable via
39
+ # `timeout:` on `register_command`, opt-out via `timeout: nil`) elapses
40
+ # before the block returns. Both codes carry the plugin name in the
41
+ # response payload's `context` so agents can branch on it.
42
+ PLUGIN_FAILED = "PLUGIN_FAILED"
43
+ PLUGIN_TIMED_OUT = "PLUGIN_TIMED_OUT"
32
44
 
33
45
  GENERIC = "GENERIC"
34
46
 
@@ -46,6 +58,9 @@ module Browserctl
46
58
  INVALID_STATE_NAME,
47
59
  INVALID_DSL_USAGE,
48
60
  INVALID_FORMAT_VERSION,
61
+ INVALID_ARGUMENT,
62
+ PLUGIN_FAILED,
63
+ PLUGIN_TIMED_OUT,
49
64
  GENERIC
50
65
  ].freeze
51
66
 
@@ -36,6 +36,13 @@ module Browserctl
36
36
  # entry in this table — drift-related raises fall through to GENERIC
37
37
  # until that code is introduced.
38
38
  #
39
+ # The plugin family (PLUGIN_FAILED, PLUGIN_TIMED_OUT) intentionally has
40
+ # no dedicated entry — plugins are Extension-zone surface (see
41
+ # api-stability.md), so their failures collapse to GENERIC (1) like the
42
+ # other Extension-adjacent codes (DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND,
43
+ # SECRET_RESOLUTION_FAILED). Agents must branch on the `code` field for
44
+ # plugin failures, not on `$?`.
45
+ #
39
46
  # The validation family (VALIDATION_FAILED parent plus INVALID_*
40
47
  # specialisations) all map to exit code 8 — agents and scripts can
41
48
  # branch on `$? == 8` for any caller-side validation failure without
@@ -50,7 +57,8 @@ module Browserctl
50
57
  Codes::INVALID_SELECTOR_REF => VALIDATION_FAILED,
51
58
  Codes::INVALID_STATE_NAME => VALIDATION_FAILED,
52
59
  Codes::INVALID_DSL_USAGE => VALIDATION_FAILED,
53
- Codes::INVALID_FORMAT_VERSION => VALIDATION_FAILED
60
+ Codes::INVALID_FORMAT_VERSION => VALIDATION_FAILED,
61
+ Codes::INVALID_ARGUMENT => VALIDATION_FAILED
54
62
  }.freeze
55
63
 
56
64
  # @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
@@ -39,6 +39,13 @@ module Browserctl
39
39
  "required blocks or arguments are missing.",
40
40
  Codes::INVALID_FORMAT_VERSION =>
41
41
  "Use a non-negative Integer for the format version header; see docs/reference/format-versions.md.",
42
+ Codes::INVALID_ARGUMENT =>
43
+ "Check the argument value against the documented contract; " \
44
+ "e.g. --scope must be one of cookies|localStorage|sessionStorage.",
45
+ Codes::PLUGIN_FAILED =>
46
+ "Check the plugin's logs; the daemon caught an uncaught exception from the plugin and is otherwise healthy.",
47
+ Codes::PLUGIN_TIMED_OUT =>
48
+ "Increase the plugin's `timeout:` on register_command, or pass `timeout: nil` to opt out (not recommended).",
42
49
  Codes::GENERIC => DEFAULT
43
50
  }.freeze
44
51
 
@@ -4,7 +4,7 @@ require_relative "callable_definition"
4
4
  require_relative "errors"
5
5
 
6
6
  module Browserctl
7
- FlowConditionDef = Struct.new(:kind, :label, :block, keyword_init: true)
7
+ FlowConditionDef = Data.define(:kind, :label, :block)
8
8
 
9
9
  # Back-compat aliases — flow_wrapper specs reference these directly.
10
10
  FlowParamDef = CallableDefinition::ParamDef
@@ -13,7 +13,7 @@ require_relative "../../flow"
13
13
  # the duck-typed (current_url, body) interface the detector expects.
14
14
  module Browserctl
15
15
  module Flows
16
- PageDetectorAdapter = Struct.new(:current_url, :body)
16
+ PageDetectorAdapter = Data.define(:current_url, :body)
17
17
 
18
18
  module CloudflareSolve
19
19
  module_function
@@ -22,11 +22,11 @@ module Browserctl
22
22
  # absolute path of the file being migrated; it is responsible for
23
23
  # rewriting that file in place, advancing it from `from_version` to
24
24
  # `to_version`.
25
- Migration = Struct.new(:format, :from_version, :to_version, :upgrade, keyword_init: true)
25
+ Migration = Data.define(:format, :from_version, :to_version, :upgrade)
26
26
 
27
27
  # Result of {.run}. `applied` is the ordered list of {Migration} steps
28
28
  # that ran. When the artifact was already at target, `applied` is empty.
29
- Result = Struct.new(:format, :from, :to, :applied, keyword_init: true)
29
+ Result = Data.define(:format, :from, :to, :applied)
30
30
 
31
31
  FORMAT_EXTENSIONS = {
32
32
  ".bctl" => :bundle,