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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +7 -3
- data/bin/browserctl +10 -2
- data/examples/tracing_otel.rb +46 -0
- data/lib/browserctl/callable_definition.rb +2 -2
- data/lib/browserctl/client.rb +41 -0
- data/lib/browserctl/commands/cookie.rb +17 -0
- data/lib/browserctl/commands/data.rb +73 -0
- data/lib/browserctl/commands/deprecation_notice.rb +33 -0
- data/lib/browserctl/commands/storage.rb +17 -0
- data/lib/browserctl/detectors/auth_required.rb +1 -1
- data/lib/browserctl/driver/cdp.rb +2 -2
- data/lib/browserctl/driver/ferrum_page_driver.rb +43 -0
- data/lib/browserctl/driver/page_driver.rb +90 -0
- data/lib/browserctl/error/codes.rb +15 -0
- data/lib/browserctl/error/exit_codes.rb +9 -1
- data/lib/browserctl/error/suggested_actions.rb +7 -0
- data/lib/browserctl/flow.rb +1 -1
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +1 -1
- data/lib/browserctl/migrations.rb +2 -2
- data/lib/browserctl/replay/context.rb +1 -1
- data/lib/browserctl/replay/fingerprint_matcher.rb +1 -1
- data/lib/browserctl/server/command_dispatcher.rb +25 -17
- data/lib/browserctl/server/handlers/cookies.rb +18 -21
- data/lib/browserctl/server/handlers/data.rb +145 -0
- data/lib/browserctl/server/handlers/devtools.rb +8 -9
- data/lib/browserctl/server/handlers/hitl.rb +10 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -23
- data/lib/browserctl/server/handlers/navigation.rb +15 -15
- data/lib/browserctl/server/handlers/observation.rb +6 -6
- data/lib/browserctl/server/handlers/page_lifecycle.rb +12 -4
- data/lib/browserctl/server/handlers/state.rb +16 -6
- data/lib/browserctl/server/handlers/storage.rb +8 -8
- data/lib/browserctl/server/page_session.rb +21 -3
- data/lib/browserctl/server/plugin_dispatcher.rb +83 -0
- data/lib/browserctl/state/mutator.rb +1 -1
- data/lib/browserctl/tracing.rb +75 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/page_proxy.rb +128 -0
- data/lib/browserctl/workflow.rb +3 -117
- data/lib/browserctl.rb +28 -2
- metadata +10 -1
|
@@ -12,6 +12,9 @@ module Browserctl
|
|
|
12
12
|
private
|
|
13
13
|
|
|
14
14
|
def cmd_state_save(req)
|
|
15
|
+
# registry-wide: state save snapshots across all open pages,
|
|
16
|
+
# not a single addressed page — picks an arbitrary first session
|
|
17
|
+
# for cookie capture (cookies are browser-global in CDP).
|
|
15
18
|
first_session = @global_mutex.synchronize { @pages.values.first }
|
|
16
19
|
return { error: "no open pages — open a page before saving state" } unless first_session
|
|
17
20
|
|
|
@@ -40,6 +43,8 @@ module Browserctl
|
|
|
40
43
|
|
|
41
44
|
def cmd_state_load(req)
|
|
42
45
|
data = Browserctl::State.load(req[:name], passphrase: req[:passphrase])
|
|
46
|
+
# registry-wide: state load applies to any open page (cookies are
|
|
47
|
+
# browser-global); first session is fine as the cookie sink.
|
|
43
48
|
target = @global_mutex.synchronize { @pages.values.first }
|
|
44
49
|
return { error: "no open pages — open a page before loading state" } unless target
|
|
45
50
|
|
|
@@ -47,7 +52,7 @@ module Browserctl
|
|
|
47
52
|
|
|
48
53
|
unless req[:skip_auth_check]
|
|
49
54
|
auth = Browserctl::Detectors.auth_required(
|
|
50
|
-
target.
|
|
55
|
+
target.driver, cookies: cookies, suggested_flow: data[:manifest][:flow]
|
|
51
56
|
)
|
|
52
57
|
if auth.triggered
|
|
53
58
|
return Browserctl::AuthRequiredError.new(
|
|
@@ -79,7 +84,7 @@ module Browserctl
|
|
|
79
84
|
def restore_state_cookies(target, cookies)
|
|
80
85
|
cookies.each do |raw|
|
|
81
86
|
c = raw.transform_keys(&:to_sym)
|
|
82
|
-
target.
|
|
87
|
+
target.driver.cookies_set(**c.slice(:name, :value, :domain, :path))
|
|
83
88
|
end
|
|
84
89
|
end
|
|
85
90
|
|
|
@@ -101,18 +106,23 @@ module Browserctl
|
|
|
101
106
|
end
|
|
102
107
|
|
|
103
108
|
def capture_state_payload
|
|
109
|
+
# registry-wide: cookies are browser-global, so any session works
|
|
110
|
+
# as the cookie source.
|
|
104
111
|
first = @global_mutex.synchronize { @pages.values.first }
|
|
105
|
-
cookies = first.
|
|
112
|
+
cookies = first.driver.cookies_all.values.map(&:to_h)
|
|
106
113
|
|
|
107
114
|
local_storage = {}
|
|
108
115
|
session_storage = {}
|
|
109
116
|
captured_origins = []
|
|
110
117
|
|
|
118
|
+
# registry-wide: iterate every page to capture per-origin storage;
|
|
119
|
+
# snapshot the registry under @global_mutex, then take each
|
|
120
|
+
# session's own mutex to read storage safely.
|
|
111
121
|
@global_mutex.synchronize { @pages.dup }.each_value do |session|
|
|
112
122
|
session.mutex.synchronize do
|
|
113
|
-
origin = session.
|
|
114
|
-
ls_str = session.
|
|
115
|
-
ss_str = session.
|
|
123
|
+
origin = session.driver.evaluate("location.origin")
|
|
124
|
+
ls_str = session.driver.evaluate("JSON.stringify({...localStorage})") || "{}"
|
|
125
|
+
ss_str = session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}"
|
|
116
126
|
local_storage[origin] = JSON.parse(ls_str)
|
|
117
127
|
session_storage[origin] = JSON.parse(ss_str)
|
|
118
128
|
captured_origins << origin
|
|
@@ -13,7 +13,7 @@ module Browserctl
|
|
|
13
13
|
js = storage_js_get(store, req[:key])
|
|
14
14
|
return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
|
|
15
15
|
|
|
16
|
-
value = session.
|
|
16
|
+
value = session.driver.evaluate(js)
|
|
17
17
|
{ ok: true, value: value }
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -25,7 +25,7 @@ module Browserctl
|
|
|
25
25
|
js = storage_js_set(store, req[:key], req[:value])
|
|
26
26
|
return { error: "unknown store '#{store}' — use 'local' or 'session'" } unless js
|
|
27
27
|
|
|
28
|
-
session.
|
|
28
|
+
session.driver.evaluate(js)
|
|
29
29
|
{ ok: true }
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -37,16 +37,16 @@ module Browserctl
|
|
|
37
37
|
stores = req.fetch(:stores, "all")
|
|
38
38
|
data = {}
|
|
39
39
|
|
|
40
|
-
origin = session.
|
|
40
|
+
origin = session.driver.evaluate("location.origin")
|
|
41
41
|
data[origin] = {}
|
|
42
42
|
|
|
43
43
|
if %w[local all].include?(stores)
|
|
44
|
-
local = JSON.parse(session.
|
|
44
|
+
local = JSON.parse(session.driver.evaluate("JSON.stringify({...localStorage})") || "{}")
|
|
45
45
|
data[origin].merge!(local)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
if %w[session all].include?(stores)
|
|
49
|
-
sess = JSON.parse(session.
|
|
49
|
+
sess = JSON.parse(session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}")
|
|
50
50
|
data[origin].merge!(sess)
|
|
51
51
|
end
|
|
52
52
|
|
|
@@ -71,7 +71,7 @@ module Browserctl
|
|
|
71
71
|
key_count = 0
|
|
72
72
|
data.each_value do |keys|
|
|
73
73
|
keys.each do |k, v|
|
|
74
|
-
session.
|
|
74
|
+
session.driver.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
|
|
75
75
|
key_count += 1
|
|
76
76
|
end
|
|
77
77
|
end
|
|
@@ -84,8 +84,8 @@ module Browserctl
|
|
|
84
84
|
def cmd_storage_delete(req)
|
|
85
85
|
with_page(req[:name]) do |session|
|
|
86
86
|
stores = req.fetch(:stores, "all")
|
|
87
|
-
session.
|
|
88
|
-
session.
|
|
87
|
+
session.driver.evaluate("localStorage.clear()") if %w[local all].include?(stores)
|
|
88
|
+
session.driver.evaluate("sessionStorage.clear()") if %w[session all].include?(stores)
|
|
89
89
|
{ ok: true }
|
|
90
90
|
end
|
|
91
91
|
end
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../driver/ferrum_page_driver"
|
|
4
|
+
|
|
3
5
|
module Browserctl
|
|
4
6
|
class PageSession
|
|
5
|
-
attr_reader :
|
|
7
|
+
attr_reader :driver, :mutex, :pause_cv
|
|
6
8
|
attr_accessor :ref_registry, :prev_snapshot, :fingerprint_index, :snapshot_id
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
# @param page_or_driver [Browserctl::Driver::PageDriver, Object] either a
|
|
11
|
+
# PageDriver (preferred) or a raw Ferrum/CDP page (auto-wrapped in
|
|
12
|
+
# {Browserctl::Driver::FerrumPageDriver} for back-compat with callers
|
|
13
|
+
# that still construct sessions from a raw page).
|
|
14
|
+
def initialize(page_or_driver)
|
|
15
|
+
@driver = if page_or_driver.is_a?(Browserctl::Driver::PageDriver)
|
|
16
|
+
page_or_driver
|
|
17
|
+
else
|
|
18
|
+
Browserctl::Driver::FerrumPageDriver.new(page_or_driver)
|
|
19
|
+
end
|
|
10
20
|
@mutex = Mutex.new
|
|
11
21
|
@pause_cv = ConditionVariable.new
|
|
12
22
|
@ref_registry = {}
|
|
@@ -16,6 +26,14 @@ module Browserctl
|
|
|
16
26
|
@paused = false
|
|
17
27
|
end
|
|
18
28
|
|
|
29
|
+
# Back-compat accessor. New handler code calls {#driver} directly; this
|
|
30
|
+
# returns the underlying Ferrum/CDP page for legacy callers (the CDP
|
|
31
|
+
# driver's `devtools_info`, the page-lifecycle close path, and a unit
|
|
32
|
+
# spec).
|
|
33
|
+
def page
|
|
34
|
+
@driver.respond_to?(:raw_page) ? @driver.raw_page : @driver
|
|
35
|
+
end
|
|
36
|
+
|
|
19
37
|
def paused? = @paused
|
|
20
38
|
def pause! = (@paused = true)
|
|
21
39
|
def resume! = (@paused = false)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "handlers/error_payload"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
# Invokes plugin commands registered via {Browserctl.register_command} from
|
|
10
|
+
# the daemon's command-dispatch loop. Extracted from `CommandDispatcher` in
|
|
11
|
+
# v0.15 WS-2 PR 5 so plugin invocation gets two daemon-protecting
|
|
12
|
+
# properties:
|
|
13
|
+
#
|
|
14
|
+
# - A per-plugin wall-clock timeout (default
|
|
15
|
+
# {Browserctl::DEFAULT_PLUGIN_TIMEOUT}, configurable via `timeout:` on
|
|
16
|
+
# `register_command`, opt-out with `timeout: nil`). On expiry the
|
|
17
|
+
# dispatcher returns a typed `PLUGIN_TIMED_OUT` response and the daemon
|
|
18
|
+
# stays answering subsequent commands.
|
|
19
|
+
# - A rescue boundary that converts ANY uncaught exception from the plugin
|
|
20
|
+
# block into a typed `PLUGIN_FAILED` JSON-RPC response. The plugin name
|
|
21
|
+
# is always present in the response's `context` so agents can branch on
|
|
22
|
+
# it. The daemon process is never taken down by a buggy plugin.
|
|
23
|
+
#
|
|
24
|
+
# Plugins live in the Extension zone (see
|
|
25
|
+
# `docs/reference/api-stability.md`); the response shape here is documented
|
|
26
|
+
# in `docs/reference/errors.md`.
|
|
27
|
+
class PluginDispatcher
|
|
28
|
+
include CommandDispatcher::Handlers::ErrorPayload
|
|
29
|
+
|
|
30
|
+
# @param pages [Hash{String => PageSession}] shared daemon page registry
|
|
31
|
+
# @param global_mutex [Mutex] mutex guarding the page registry
|
|
32
|
+
def initialize(pages, global_mutex:)
|
|
33
|
+
@pages = pages
|
|
34
|
+
@global_mutex = global_mutex
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Looks up the plugin command for `req[:cmd]` and invokes it under the
|
|
38
|
+
# timeout / rescue boundary. Returns `nil` if no plugin handles this
|
|
39
|
+
# command — the caller falls through to its "unknown command" branch.
|
|
40
|
+
#
|
|
41
|
+
# @param req [Hash{Symbol => Object}] parsed request
|
|
42
|
+
# @return [Hash{Symbol => Object}, nil]
|
|
43
|
+
def dispatch(req)
|
|
44
|
+
plugin = Browserctl.lookup_plugin_command(req[:cmd])
|
|
45
|
+
return nil unless plugin
|
|
46
|
+
|
|
47
|
+
name = req[:cmd].to_s
|
|
48
|
+
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
49
|
+
|
|
50
|
+
Browserctl.logger.debug("plugin:#{name} #{req[:name]}")
|
|
51
|
+
invoke(plugin, name, session, req)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def invoke(plugin, name, session, req)
|
|
57
|
+
response = if plugin.timeout
|
|
58
|
+
Timeout.timeout(plugin.timeout) { plugin.block.call(session, req) }
|
|
59
|
+
else
|
|
60
|
+
plugin.block.call(session, req)
|
|
61
|
+
end
|
|
62
|
+
# Verify the response will survive the daemon's JSON serialiser. A plugin
|
|
63
|
+
# that returns an unencodable object would otherwise blow up at the
|
|
64
|
+
# socket-write step, outside this rescue boundary.
|
|
65
|
+
JSON.generate(response)
|
|
66
|
+
response
|
|
67
|
+
rescue Timeout::Error
|
|
68
|
+
Browserctl.logger.warn("plugin:#{name} timed out after #{plugin.timeout}s")
|
|
69
|
+
error_payload(
|
|
70
|
+
code: Browserctl::Error::Codes::PLUGIN_TIMED_OUT,
|
|
71
|
+
message: "plugin '#{name}' timed out after #{plugin.timeout}s",
|
|
72
|
+
context: { plugin: name, timeout: plugin.timeout }
|
|
73
|
+
)
|
|
74
|
+
rescue StandardError, ScriptError => e
|
|
75
|
+
Browserctl.logger.warn("plugin:#{name} raised #{e.class}: #{e.message}")
|
|
76
|
+
error_payload(
|
|
77
|
+
code: Browserctl::Error::Codes::PLUGIN_FAILED,
|
|
78
|
+
message: "plugin '#{name}' failed: #{e.class}: #{e.message}",
|
|
79
|
+
context: { plugin: name, exception: e.class.name }
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -16,7 +16,7 @@ module Browserctl
|
|
|
16
16
|
# caller (CLI or Workflow) can decide how to render them. The CLI maps
|
|
17
17
|
# to a non-zero exit; a Workflow caller may catch and continue.
|
|
18
18
|
class Mutator
|
|
19
|
-
Result =
|
|
19
|
+
Result = Data.define(:save_result, :flow_name, :flow_version) do
|
|
20
20
|
def to_h
|
|
21
21
|
(save_result || {}).merge(rotated_flow: flow_name)
|
|
22
22
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
# Pluggable tracing seam shaped like OpenTelemetry's span API, without
|
|
5
|
+
# taking an OpenTelemetry dependency.
|
|
6
|
+
#
|
|
7
|
+
# Backend contract
|
|
8
|
+
# ----------------
|
|
9
|
+
#
|
|
10
|
+
# A tracing backend must respond to two methods:
|
|
11
|
+
#
|
|
12
|
+
# start_span(name, attributes:) -> span
|
|
13
|
+
# - `name` is a String (e.g. "command.navigate").
|
|
14
|
+
# - `attributes` is a Hash of additional span tags.
|
|
15
|
+
# - Returns an opaque span object that will be passed back to
|
|
16
|
+
# `end_span`. May return `nil`; `end_span` must tolerate that.
|
|
17
|
+
#
|
|
18
|
+
# end_span(span, status:, attributes: {})
|
|
19
|
+
# - `span` is whatever `start_span` returned.
|
|
20
|
+
# - `status` is `:ok` or `:error`.
|
|
21
|
+
# - `attributes` carries any tags computed at close time
|
|
22
|
+
# (notably `duration_ms`). Optional.
|
|
23
|
+
#
|
|
24
|
+
# The default backend is `NoopBackend` — it does nothing. A custom
|
|
25
|
+
# backend is wired in via `Browserctl::Tracing.backend = ...`.
|
|
26
|
+
# The contract is part of the Extension zone — see
|
|
27
|
+
# `docs/reference/api-stability.md`.
|
|
28
|
+
module Tracing
|
|
29
|
+
# No-op default. Implements the Backend contract above.
|
|
30
|
+
class NoopBackend
|
|
31
|
+
def start_span(_name, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def end_span(_span, status: :ok, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@backend_mutex = Mutex.new
|
|
41
|
+
@backend = NoopBackend.new
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def backend
|
|
45
|
+
@backend_mutex.synchronize { @backend }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def backend=(value)
|
|
49
|
+
@backend_mutex.synchronize { @backend = value || NoopBackend.new }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wraps a block in a span. Computes `duration_ms` and routes
|
|
53
|
+
# exceptions to `end_span(span, status: :error)`.
|
|
54
|
+
def in_span(name, attributes: {})
|
|
55
|
+
bk = backend
|
|
56
|
+
span = bk.start_span(name, attributes: attributes)
|
|
57
|
+
start = monotonic_ms
|
|
58
|
+
begin
|
|
59
|
+
result = yield
|
|
60
|
+
rescue StandardError
|
|
61
|
+
bk.end_span(span, status: :error, attributes: { duration_ms: monotonic_ms - start })
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
bk.end_span(span, status: :ok, attributes: { duration_ms: monotonic_ms - start })
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def monotonic_ms
|
|
71
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/browserctl/version.rb
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../replay/fingerprint_matcher"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
# Per-page wrapper handed to workflow step blocks via `page(:name)`. Forwards
|
|
8
|
+
# one-liners to the daemon `Client`, unwraps the response, and — when a replay
|
|
9
|
+
# context is attached — falls back to fingerprint-based rematching when a
|
|
10
|
+
# selector goes missing.
|
|
11
|
+
#
|
|
12
|
+
# Extracted from `workflow.rb` so the proxy can be loaded and reasoned about
|
|
13
|
+
# independently of the workflow DSL / registry; behaviour is unchanged.
|
|
14
|
+
class PageProxy
|
|
15
|
+
attr_accessor :replay_context
|
|
16
|
+
|
|
17
|
+
# Declarative wrapper for `unwrap @client.METHOD(@name, ...)` one-liners.
|
|
18
|
+
# Forwards positional + keyword args verbatim. Pass `extract:` to return
|
|
19
|
+
# a single key from the client response instead of unwrapping.
|
|
20
|
+
def self.delegate_unwrap(method_name, extract: nil)
|
|
21
|
+
if extract
|
|
22
|
+
define_method(method_name) do |*args, **kwargs|
|
|
23
|
+
@client.public_send(method_name, @name, *args, **kwargs)[extract]
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
define_method(method_name) do |*args, **kwargs|
|
|
27
|
+
unwrap @client.public_send(method_name, @name, *args, **kwargs)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(name, client, replay_context: nil, matcher: nil)
|
|
33
|
+
@name = name
|
|
34
|
+
@client = client
|
|
35
|
+
@replay_context = replay_context
|
|
36
|
+
@matcher = matcher || Replay::FingerprintMatcher.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
delegate_unwrap :navigate
|
|
40
|
+
delegate_unwrap :snapshot
|
|
41
|
+
delegate_unwrap :screenshot
|
|
42
|
+
delegate_unwrap :wait
|
|
43
|
+
delegate_unwrap :delete_cookies
|
|
44
|
+
delegate_unwrap :press
|
|
45
|
+
delegate_unwrap :storage_set
|
|
46
|
+
delegate_unwrap :dialog_accept
|
|
47
|
+
delegate_unwrap :dialog_dismiss
|
|
48
|
+
|
|
49
|
+
delegate_unwrap :devtools, extract: :devtools_url
|
|
50
|
+
delegate_unwrap :url, extract: :url
|
|
51
|
+
delegate_unwrap :evaluate, extract: :result
|
|
52
|
+
delegate_unwrap :storage_get, extract: :value
|
|
53
|
+
|
|
54
|
+
def fill(selector = nil, value = nil, ref: nil)
|
|
55
|
+
with_selector_fallback(:fill, selector, ref) do |sel, r|
|
|
56
|
+
@client.fill(@name, sel, value, ref: r)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def click(selector = nil, ref: nil)
|
|
61
|
+
with_selector_fallback(:click, selector, ref) do |sel, r|
|
|
62
|
+
@client.click(@name, sel, ref: r)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hover(selector = nil, ref: nil)
|
|
67
|
+
with_selector_fallback(:hover, selector, ref) do |sel, r|
|
|
68
|
+
@client.hover(@name, sel, ref: r)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def upload(selector = nil, path = nil, ref: nil)
|
|
73
|
+
with_selector_fallback(:upload, selector, ref) do |sel, r|
|
|
74
|
+
@client.upload(@name, sel, path, ref: r)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def select(selector = nil, value = nil, ref: nil)
|
|
79
|
+
with_selector_fallback(:select, selector, ref) do |sel, r|
|
|
80
|
+
@client.select(@name, sel, value, ref: r)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Issues the wrapped command. If the daemon returns selector_not_found
|
|
87
|
+
# and a replay context has a fingerprint for this selector, takes a
|
|
88
|
+
# fresh snapshot, asks the matcher for a candidate, and retries by ref.
|
|
89
|
+
def with_selector_fallback(cmd, selector, ref)
|
|
90
|
+
res = yield(selector, ref)
|
|
91
|
+
return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
|
|
92
|
+
|
|
93
|
+
fp = @replay_context.fingerprint_for(selector)
|
|
94
|
+
return unwrap(res) unless fp
|
|
95
|
+
|
|
96
|
+
match = @matcher.best(fp, snapshot_entries)
|
|
97
|
+
unless match
|
|
98
|
+
@replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
|
|
99
|
+
return unwrap(res)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
log_rematch(cmd, selector, match)
|
|
103
|
+
@replay_context.record(command: cmd, selector: selector,
|
|
104
|
+
matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
|
|
105
|
+
unwrap(yield(nil, match.candidate[:ref]))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def snapshot_entries
|
|
109
|
+
res = @client.snapshot(@name, format: "elements")
|
|
110
|
+
Array(res[:snapshot])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def selector_not_found?(res)
|
|
114
|
+
res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def log_rematch(cmd, selector, match)
|
|
118
|
+
warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
|
|
119
|
+
"rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unwrap(res)
|
|
123
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
124
|
+
|
|
125
|
+
res
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "flow_registry"
|
|
|
8
8
|
require_relative "replay/context"
|
|
9
9
|
require_relative "replay/fingerprint_matcher"
|
|
10
10
|
require_relative "replay/snapshot_diff"
|
|
11
|
+
require_relative "workflow/page_proxy"
|
|
11
12
|
|
|
12
13
|
module Browserctl
|
|
13
14
|
# Workflow-file format version. Workflows are Ruby files; the schema gate
|
|
@@ -69,7 +70,7 @@ module Browserctl
|
|
|
69
70
|
# workflow specs reference these directly).
|
|
70
71
|
ParamDef = CallableDefinition::ParamDef
|
|
71
72
|
StepDef = CallableDefinition::StepDef
|
|
72
|
-
StepResult =
|
|
73
|
+
StepResult = Data.define(:name, :ok, :error)
|
|
73
74
|
|
|
74
75
|
class WorkflowContext
|
|
75
76
|
include ContextualPersistence
|
|
@@ -193,121 +194,6 @@ module Browserctl
|
|
|
193
194
|
end
|
|
194
195
|
end
|
|
195
196
|
|
|
196
|
-
class PageProxy
|
|
197
|
-
attr_accessor :replay_context
|
|
198
|
-
|
|
199
|
-
# Declarative wrapper for `unwrap @client.METHOD(@name, ...)` one-liners.
|
|
200
|
-
# Forwards positional + keyword args verbatim. Pass `extract:` to return
|
|
201
|
-
# a single key from the client response instead of unwrapping.
|
|
202
|
-
def self.delegate_unwrap(method_name, extract: nil)
|
|
203
|
-
if extract
|
|
204
|
-
define_method(method_name) do |*args, **kwargs|
|
|
205
|
-
@client.public_send(method_name, @name, *args, **kwargs)[extract]
|
|
206
|
-
end
|
|
207
|
-
else
|
|
208
|
-
define_method(method_name) do |*args, **kwargs|
|
|
209
|
-
unwrap @client.public_send(method_name, @name, *args, **kwargs)
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def initialize(name, client, replay_context: nil, matcher: nil)
|
|
215
|
-
@name = name
|
|
216
|
-
@client = client
|
|
217
|
-
@replay_context = replay_context
|
|
218
|
-
@matcher = matcher || Replay::FingerprintMatcher.new
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
delegate_unwrap :navigate
|
|
222
|
-
delegate_unwrap :snapshot
|
|
223
|
-
delegate_unwrap :screenshot
|
|
224
|
-
delegate_unwrap :wait
|
|
225
|
-
delegate_unwrap :delete_cookies
|
|
226
|
-
delegate_unwrap :press
|
|
227
|
-
delegate_unwrap :storage_set
|
|
228
|
-
delegate_unwrap :dialog_accept
|
|
229
|
-
delegate_unwrap :dialog_dismiss
|
|
230
|
-
|
|
231
|
-
delegate_unwrap :devtools, extract: :devtools_url
|
|
232
|
-
delegate_unwrap :url, extract: :url
|
|
233
|
-
delegate_unwrap :evaluate, extract: :result
|
|
234
|
-
delegate_unwrap :storage_get, extract: :value
|
|
235
|
-
|
|
236
|
-
def fill(selector = nil, value = nil, ref: nil)
|
|
237
|
-
with_selector_fallback(:fill, selector, ref) do |sel, r|
|
|
238
|
-
@client.fill(@name, sel, value, ref: r)
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def click(selector = nil, ref: nil)
|
|
243
|
-
with_selector_fallback(:click, selector, ref) do |sel, r|
|
|
244
|
-
@client.click(@name, sel, ref: r)
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def hover(selector = nil, ref: nil)
|
|
249
|
-
with_selector_fallback(:hover, selector, ref) do |sel, r|
|
|
250
|
-
@client.hover(@name, sel, ref: r)
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def upload(selector = nil, path = nil, ref: nil)
|
|
255
|
-
with_selector_fallback(:upload, selector, ref) do |sel, r|
|
|
256
|
-
@client.upload(@name, sel, path, ref: r)
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def select(selector = nil, value = nil, ref: nil)
|
|
261
|
-
with_selector_fallback(:select, selector, ref) do |sel, r|
|
|
262
|
-
@client.select(@name, sel, value, ref: r)
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
private
|
|
267
|
-
|
|
268
|
-
# Issues the wrapped command. If the daemon returns selector_not_found
|
|
269
|
-
# and a replay context has a fingerprint for this selector, takes a
|
|
270
|
-
# fresh snapshot, asks the matcher for a candidate, and retries by ref.
|
|
271
|
-
def with_selector_fallback(cmd, selector, ref)
|
|
272
|
-
res = yield(selector, ref)
|
|
273
|
-
return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
|
|
274
|
-
|
|
275
|
-
fp = @replay_context.fingerprint_for(selector)
|
|
276
|
-
return unwrap(res) unless fp
|
|
277
|
-
|
|
278
|
-
match = @matcher.best(fp, snapshot_entries)
|
|
279
|
-
unless match
|
|
280
|
-
@replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
|
|
281
|
-
return unwrap(res)
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
log_rematch(cmd, selector, match)
|
|
285
|
-
@replay_context.record(command: cmd, selector: selector,
|
|
286
|
-
matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
|
|
287
|
-
unwrap(yield(nil, match.candidate[:ref]))
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def snapshot_entries
|
|
291
|
-
res = @client.snapshot(@name, format: "elements")
|
|
292
|
-
Array(res[:snapshot])
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def selector_not_found?(res)
|
|
296
|
-
res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def log_rematch(cmd, selector, match)
|
|
300
|
-
warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
|
|
301
|
-
"rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def unwrap(res)
|
|
305
|
-
raise WorkflowError, res[:error] if res[:error]
|
|
306
|
-
|
|
307
|
-
res
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
197
|
class WorkflowDefinition < CallableDefinition
|
|
312
198
|
def callable_kind
|
|
313
199
|
:workflow
|
|
@@ -359,7 +245,7 @@ module Browserctl
|
|
|
359
245
|
last_error = nil
|
|
360
246
|
(defn.retry_count + 1).times do
|
|
361
247
|
execute_step_block(ctx, defn)
|
|
362
|
-
return StepResult.new(name: defn.label, ok: true)
|
|
248
|
+
return StepResult.new(name: defn.label, ok: true, error: nil)
|
|
363
249
|
rescue StandardError => e
|
|
364
250
|
last_error = e
|
|
365
251
|
end
|
data/lib/browserctl.rb
CHANGED
|
@@ -4,16 +4,42 @@ require_relative "browserctl/version"
|
|
|
4
4
|
require_relative "browserctl/constants"
|
|
5
5
|
require_relative "browserctl/errors"
|
|
6
6
|
require_relative "browserctl/secret_resolvers"
|
|
7
|
+
require_relative "browserctl/tracing"
|
|
7
8
|
require_relative "browserctl/workflow"
|
|
8
9
|
require_relative "browserctl/runner"
|
|
9
10
|
require_relative "browserctl/client"
|
|
10
11
|
|
|
11
12
|
module Browserctl
|
|
13
|
+
# Default per-plugin command timeout (seconds). Plugins registered via
|
|
14
|
+
# {register_command} without an explicit `timeout:` are wrapped in this
|
|
15
|
+
# cap by `Browserctl::PluginDispatcher`. Pass `timeout: nil` on
|
|
16
|
+
# `register_command` to opt out (not recommended — a runaway plugin will
|
|
17
|
+
# hold the daemon's command thread until the process is restarted).
|
|
18
|
+
DEFAULT_PLUGIN_TIMEOUT = 30
|
|
19
|
+
|
|
20
|
+
# Value object carried by the plugin registry. `block` is the plugin's
|
|
21
|
+
# `(session, req) -> response` callable; `timeout` is the per-command wall
|
|
22
|
+
# clock in seconds (or `nil` for no timeout).
|
|
23
|
+
PluginCommand = Struct.new(:block, :timeout, keyword_init: true)
|
|
24
|
+
|
|
12
25
|
@plugin_commands_mutex = Mutex.new
|
|
13
26
|
@plugin_commands = {}
|
|
14
27
|
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
# Registers a plugin command callable under `name`. The block receives
|
|
29
|
+
# `(session, req)` and must return a JSON-RPC-shaped Hash. The daemon wraps
|
|
30
|
+
# every invocation in a per-plugin timeout (default
|
|
31
|
+
# {DEFAULT_PLUGIN_TIMEOUT}) and a rescue boundary that converts any
|
|
32
|
+
# uncaught exception into a typed `PLUGIN_FAILED` response without taking
|
|
33
|
+
# down the daemon — see {Browserctl::PluginDispatcher}.
|
|
34
|
+
#
|
|
35
|
+
# @param name [String, Symbol] command verb on the wire
|
|
36
|
+
# @param timeout [Numeric, nil] per-invocation timeout in seconds;
|
|
37
|
+
# `nil` opts out of the timeout entirely. Defaults to
|
|
38
|
+
# {DEFAULT_PLUGIN_TIMEOUT}.
|
|
39
|
+
def self.register_command(name, timeout: DEFAULT_PLUGIN_TIMEOUT, &block)
|
|
40
|
+
@plugin_commands_mutex.synchronize do
|
|
41
|
+
@plugin_commands[name.to_s] = PluginCommand.new(block: block, timeout: timeout)
|
|
42
|
+
end
|
|
17
43
|
end
|
|
18
44
|
|
|
19
45
|
def self.lookup_plugin_command(name)
|