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
|
@@ -16,7 +16,7 @@ module Browserctl
|
|
|
16
16
|
# context so the surrounding workflow runner can render them into a
|
|
17
17
|
# drift report at end-of-run.
|
|
18
18
|
class Context
|
|
19
|
-
DriftEvent =
|
|
19
|
+
DriftEvent = Data.define(:command, :selector, :matched_ref, :score, :reason)
|
|
20
20
|
|
|
21
21
|
attr_reader :drift_events
|
|
22
22
|
|
|
@@ -21,7 +21,7 @@ module Browserctl
|
|
|
21
21
|
DEFAULT_THRESHOLD = 0.6
|
|
22
22
|
WEIGHTS = { text: 0.40, role: 0.20, neighbors: 0.25, position: 0.15 }.freeze
|
|
23
23
|
|
|
24
|
-
Match =
|
|
24
|
+
Match = Data.define(:candidate, :score)
|
|
25
25
|
|
|
26
26
|
def initialize(threshold: DEFAULT_THRESHOLD, weights: WEIGHTS)
|
|
27
27
|
@threshold = threshold
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
4
|
require_relative "page_session"
|
|
5
5
|
require_relative "handlers/error_payload"
|
|
6
|
+
require_relative "plugin_dispatcher"
|
|
6
7
|
require_relative "handlers/page_lifecycle"
|
|
7
8
|
require_relative "handlers/navigation"
|
|
8
9
|
require_relative "handlers/observation"
|
|
@@ -11,11 +12,13 @@ require_relative "handlers/hitl"
|
|
|
11
12
|
require_relative "handlers/devtools"
|
|
12
13
|
require_relative "handlers/daemon_control"
|
|
13
14
|
require_relative "handlers/storage"
|
|
15
|
+
require_relative "handlers/data"
|
|
14
16
|
require_relative "handlers/state"
|
|
15
17
|
require_relative "handlers/interaction"
|
|
16
18
|
require_relative "../detectors"
|
|
17
19
|
require_relative "../policy"
|
|
18
20
|
require_relative "../errors"
|
|
21
|
+
require_relative "../tracing"
|
|
19
22
|
require_relative "../replay/snapshot_diff"
|
|
20
23
|
|
|
21
24
|
module Browserctl
|
|
@@ -29,6 +32,7 @@ module Browserctl
|
|
|
29
32
|
include Handlers::DevTools
|
|
30
33
|
include Handlers::DaemonControl
|
|
31
34
|
include Handlers::Storage
|
|
35
|
+
include Handlers::Data
|
|
32
36
|
include Handlers::StateRpc
|
|
33
37
|
include Handlers::Interaction
|
|
34
38
|
|
|
@@ -62,6 +66,10 @@ module Browserctl
|
|
|
62
66
|
"storage_export" => :cmd_storage_export,
|
|
63
67
|
"storage_import" => :cmd_storage_import,
|
|
64
68
|
"storage_delete" => :cmd_storage_delete,
|
|
69
|
+
"data_get" => :cmd_data_get,
|
|
70
|
+
"data_set" => :cmd_data_set,
|
|
71
|
+
"data_delete" => :cmd_data_delete,
|
|
72
|
+
"data_list" => :cmd_data_list,
|
|
65
73
|
"press" => :cmd_press,
|
|
66
74
|
"hover" => :cmd_hover,
|
|
67
75
|
"upload" => :cmd_upload,
|
|
@@ -80,12 +88,13 @@ module Browserctl
|
|
|
80
88
|
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
81
89
|
|
|
82
90
|
def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
83
|
-
@pages
|
|
84
|
-
@driver
|
|
85
|
-
@snapshot_builder
|
|
86
|
-
@global_mutex
|
|
87
|
-
@kv_store
|
|
88
|
-
@kv_mutex
|
|
91
|
+
@pages = pages
|
|
92
|
+
@driver = driver
|
|
93
|
+
@snapshot_builder = snapshot_builder
|
|
94
|
+
@global_mutex = global_mutex
|
|
95
|
+
@kv_store = {}
|
|
96
|
+
@kv_mutex = Mutex.new
|
|
97
|
+
@plugin_dispatcher = PluginDispatcher.new(@pages, global_mutex: @global_mutex)
|
|
89
98
|
end
|
|
90
99
|
|
|
91
100
|
# Dispatches a parsed request to the appropriate handler.
|
|
@@ -93,13 +102,15 @@ module Browserctl
|
|
|
93
102
|
# @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
|
|
94
103
|
# @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
|
|
95
104
|
def dispatch(req)
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
Tracing.in_span("command.#{req[:cmd]}", attributes: { command: req[:cmd], page: req[:name] }) do
|
|
106
|
+
builtin = dispatch_builtin(req)
|
|
107
|
+
next builtin if builtin
|
|
98
108
|
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
plugin = dispatch_plugin(req)
|
|
110
|
+
next plugin if plugin
|
|
101
111
|
|
|
102
|
-
|
|
112
|
+
{ error: "unknown command: #{req[:cmd]}" }
|
|
113
|
+
end
|
|
103
114
|
end
|
|
104
115
|
|
|
105
116
|
private
|
|
@@ -116,13 +127,10 @@ module Browserctl
|
|
|
116
127
|
|
|
117
128
|
# Routes the request to a registered plugin command if one matches.
|
|
118
129
|
# Returns the plugin response, or `nil` if no plugin handles `req[:cmd]`.
|
|
130
|
+
# Plugin invocation, timeout, and rescue boundary all live in
|
|
131
|
+
# {PluginDispatcher} — see v0.15 WS-2 PR 5.
|
|
119
132
|
def dispatch_plugin(req)
|
|
120
|
-
|
|
121
|
-
return nil unless plugin
|
|
122
|
-
|
|
123
|
-
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
124
|
-
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
125
|
-
plugin.call(session, req)
|
|
133
|
+
@plugin_dispatcher.dispatch(req)
|
|
126
134
|
end
|
|
127
135
|
|
|
128
136
|
def with_page(name)
|
|
@@ -7,38 +7,35 @@ module Browserctl
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def cmd_cookies(req)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
10
|
+
with_page(req[:name]) do |session|
|
|
11
|
+
all = session.driver.cookies_all
|
|
12
|
+
{ ok: true, cookies: all.values.map(&:to_h) }
|
|
13
|
+
end
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
def cmd_set_cookie(req)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ ok: true }
|
|
17
|
+
with_page(req[:name]) do |session|
|
|
18
|
+
session.driver.cookies_set(
|
|
19
|
+
name: req[:cookie_name],
|
|
20
|
+
value: req[:value],
|
|
21
|
+
domain: req[:domain],
|
|
22
|
+
path: req.fetch(:path, "/")
|
|
23
|
+
)
|
|
24
|
+
{ ok: true }
|
|
25
|
+
end
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def cmd_delete_cookies(req)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
{ ok: true }
|
|
29
|
+
with_page(req[:name]) do |session|
|
|
30
|
+
session.driver.cookies_clear
|
|
31
|
+
{ ok: true }
|
|
32
|
+
end
|
|
36
33
|
end
|
|
37
34
|
|
|
38
35
|
def cmd_import_cookies(req)
|
|
39
36
|
with_page(req[:name]) do |session|
|
|
40
37
|
req[:cookies].each do |c|
|
|
41
|
-
session.
|
|
38
|
+
session.driver.cookies_set(
|
|
42
39
|
name: c[:name],
|
|
43
40
|
value: c[:value],
|
|
44
41
|
domain: c[:domain],
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
class CommandDispatcher
|
|
5
|
+
module Handlers
|
|
6
|
+
# `data` is the unified verb family that subsumes `cookie *` and
|
|
7
|
+
# `storage *` (introduced in v0.15 via ADR-0021). Every operation takes
|
|
8
|
+
# a required `scope` ∈ {cookies, localStorage, sessionStorage} and
|
|
9
|
+
# returns a unified envelope shape — see docs/reference/commands.md
|
|
10
|
+
# for the full table.
|
|
11
|
+
#
|
|
12
|
+
# The legacy `cookies`, `set_cookie`, `delete_cookies`, `import_cookies`,
|
|
13
|
+
# `storage_*` handlers remain as aliases that delegate here so the old
|
|
14
|
+
# wire verbs keep working for the v0.15 deprecation window.
|
|
15
|
+
module Data
|
|
16
|
+
VALID_SCOPES = %w[cookies localStorage sessionStorage].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# @return [Hash] unified response with `ok`, `scope`, plus per-op fields
|
|
21
|
+
def cmd_data_get(req)
|
|
22
|
+
scope = validate_scope(req[:scope])
|
|
23
|
+
return scope if scope.is_a?(Hash) # error envelope
|
|
24
|
+
|
|
25
|
+
case scope
|
|
26
|
+
when "cookies"
|
|
27
|
+
{ error: "data get is not supported for scope 'cookies' — use 'data list'",
|
|
28
|
+
code: Browserctl::Error::Codes::INVALID_ARGUMENT }
|
|
29
|
+
when "localStorage", "sessionStorage"
|
|
30
|
+
with_page(req[:name]) do |session|
|
|
31
|
+
js = storage_get_js(scope, req[:key])
|
|
32
|
+
value = session.driver.evaluate(js)
|
|
33
|
+
{ ok: true, scope: scope, key: req[:key], value: value }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cmd_data_set(req)
|
|
39
|
+
scope = validate_scope(req[:scope])
|
|
40
|
+
return scope if scope.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
case scope
|
|
43
|
+
when "cookies"
|
|
44
|
+
with_page(req[:name]) do |session|
|
|
45
|
+
unless req[:domain]
|
|
46
|
+
next({ error: "data set --scope cookies requires --domain",
|
|
47
|
+
code: Browserctl::Error::Codes::INVALID_ARGUMENT })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
session.driver.cookies_set(
|
|
51
|
+
name: req[:key],
|
|
52
|
+
value: req[:value],
|
|
53
|
+
domain: req[:domain],
|
|
54
|
+
path: req.fetch(:path, "/")
|
|
55
|
+
)
|
|
56
|
+
{ ok: true, scope: scope, key: req[:key] }
|
|
57
|
+
end
|
|
58
|
+
when "localStorage", "sessionStorage"
|
|
59
|
+
with_page(req[:name]) do |session|
|
|
60
|
+
js = storage_set_js(scope, req[:key], req[:value])
|
|
61
|
+
session.driver.evaluate(js)
|
|
62
|
+
{ ok: true, scope: scope, key: req[:key] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cmd_data_delete(req)
|
|
68
|
+
scope = validate_scope(req[:scope])
|
|
69
|
+
return scope if scope.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
case scope
|
|
72
|
+
when "cookies"
|
|
73
|
+
with_page(req[:name]) do |session|
|
|
74
|
+
count = session.driver.cookies_all.length
|
|
75
|
+
session.driver.cookies_clear
|
|
76
|
+
{ ok: true, scope: scope, deleted: count }
|
|
77
|
+
end
|
|
78
|
+
when "localStorage"
|
|
79
|
+
with_page(req[:name]) do |session|
|
|
80
|
+
count = JSON.parse(session.driver.evaluate("JSON.stringify({...localStorage})") || "{}").length
|
|
81
|
+
session.driver.evaluate("localStorage.clear()")
|
|
82
|
+
{ ok: true, scope: scope, deleted: count }
|
|
83
|
+
end
|
|
84
|
+
when "sessionStorage"
|
|
85
|
+
with_page(req[:name]) do |session|
|
|
86
|
+
count = JSON.parse(session.driver.evaluate("JSON.stringify({...sessionStorage})") || "{}").length
|
|
87
|
+
session.driver.evaluate("sessionStorage.clear()")
|
|
88
|
+
{ ok: true, scope: scope, deleted: count }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cmd_data_list(req)
|
|
94
|
+
scope = validate_scope(req[:scope])
|
|
95
|
+
return scope if scope.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
case scope
|
|
98
|
+
when "cookies"
|
|
99
|
+
with_page(req[:name]) do |session|
|
|
100
|
+
entries = session.driver.cookies_all.values.map(&:to_h)
|
|
101
|
+
{ ok: true, scope: scope, entries: entries, count: entries.length }
|
|
102
|
+
end
|
|
103
|
+
when "localStorage", "sessionStorage"
|
|
104
|
+
with_page(req[:name]) do |session|
|
|
105
|
+
raw = session.driver.evaluate("JSON.stringify({...#{scope}})") || "{}"
|
|
106
|
+
parsed = JSON.parse(raw)
|
|
107
|
+
entries = parsed.map { |k, v| { key: k, value: v } }
|
|
108
|
+
{ ok: true, scope: scope, entries: entries, count: entries.length }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns the canonical scope string, or an error envelope on bad input.
|
|
114
|
+
def validate_scope(raw)
|
|
115
|
+
return invalid_scope_error(raw) if raw.nil?
|
|
116
|
+
|
|
117
|
+
scope = raw.to_s
|
|
118
|
+
# Accept short forms as v0.15-only wire aliases (per ADR-0021).
|
|
119
|
+
scope = "localStorage" if scope == "local"
|
|
120
|
+
scope = "sessionStorage" if scope == "session"
|
|
121
|
+
return invalid_scope_error(raw) unless VALID_SCOPES.include?(scope)
|
|
122
|
+
|
|
123
|
+
scope
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def invalid_scope_error(raw)
|
|
127
|
+
{
|
|
128
|
+
error: "invalid --scope '#{raw}' — expected one of: #{VALID_SCOPES.join(', ')}",
|
|
129
|
+
code: Browserctl::Error::Codes::INVALID_ARGUMENT
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def storage_get_js(scope, key)
|
|
134
|
+
target = scope == "localStorage" ? "localStorage" : "sessionStorage"
|
|
135
|
+
"#{target}.getItem(#{key.to_json})"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def storage_set_js(scope, key, value)
|
|
139
|
+
target = scope == "localStorage" ? "localStorage" : "sessionStorage"
|
|
140
|
+
"#{target}.setItem(#{key.to_json}, #{value.to_json})"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -9,15 +9,14 @@ module Browserctl
|
|
|
9
9
|
def cmd_devtools(req)
|
|
10
10
|
return { error: "devtools is not supported by this driver" } unless @driver.supports?(:devtools)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{ ok: true, devtools_url: devtools_url }
|
|
12
|
+
with_page(req[:name]) do |session|
|
|
13
|
+
info = @driver.devtools_info(session.driver)
|
|
14
|
+
port = info[:port]
|
|
15
|
+
target_id = info[:target_id]
|
|
16
|
+
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
17
|
+
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
18
|
+
{ ok: true, devtools_url: devtools_url }
|
|
19
|
+
end
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
end
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
module Browserctl
|
|
4
4
|
class CommandDispatcher
|
|
5
5
|
module Handlers
|
|
6
|
+
# HITL pause/resume cannot route through `with_page` because `with_page`
|
|
7
|
+
# acquires `session.mutex` and waits on `session.pause_cv` while paused.
|
|
8
|
+
# Pause sets that flag; resume signals the CV. Reentering `with_page`
|
|
9
|
+
# would deadlock — resume would never get the lock to clear the flag.
|
|
10
|
+
# So these handlers look up the session under `@global_mutex` directly,
|
|
11
|
+
# then manage `session.mutex` / `pause_cv` themselves.
|
|
6
12
|
module Hitl
|
|
7
13
|
private
|
|
8
14
|
|
|
9
15
|
def cmd_pause(req)
|
|
16
|
+
# registry lookup: HITL manages session.mutex/pause_cv directly,
|
|
17
|
+
# cannot reenter with_page (would deadlock against pause_cv wait).
|
|
10
18
|
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
19
|
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
20
|
|
|
@@ -16,6 +24,8 @@ module Browserctl
|
|
|
16
24
|
end
|
|
17
25
|
|
|
18
26
|
def cmd_resume(req)
|
|
27
|
+
# registry lookup: HITL manages session.mutex/pause_cv directly,
|
|
28
|
+
# cannot reenter with_page (would deadlock against pause_cv wait).
|
|
19
29
|
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
20
30
|
return { error: "no page named '#{req[:name]}'" } unless session
|
|
21
31
|
|
|
@@ -8,8 +8,8 @@ module Browserctl
|
|
|
8
8
|
|
|
9
9
|
def cmd_press(req)
|
|
10
10
|
with_page(req[:name]) do |session|
|
|
11
|
-
session.
|
|
12
|
-
session.
|
|
11
|
+
session.driver.keyboard_down(req[:key])
|
|
12
|
+
session.driver.keyboard_up(req[:key])
|
|
13
13
|
{ ok: true }
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -19,7 +19,7 @@ module Browserctl
|
|
|
19
19
|
sel = resolve_selector_from(session, req)
|
|
20
20
|
return sel if sel.is_a?(Hash)
|
|
21
21
|
|
|
22
|
-
coords = session.
|
|
22
|
+
coords = session.driver.evaluate(
|
|
23
23
|
"(function(sel) { " \
|
|
24
24
|
"var el = document.querySelector(sel); " \
|
|
25
25
|
"if (!el) return null; " \
|
|
@@ -35,7 +35,7 @@ module Browserctl
|
|
|
35
35
|
)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
session.
|
|
38
|
+
session.driver.mouse_move(x: coords["x"], y: coords["y"])
|
|
39
39
|
{ ok: true }
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -48,7 +48,7 @@ module Browserctl
|
|
|
48
48
|
sel = resolve_selector_from(session, req)
|
|
49
49
|
return sel if sel.is_a?(Hash)
|
|
50
50
|
|
|
51
|
-
el = session.
|
|
51
|
+
el = session.driver.at_css(sel)
|
|
52
52
|
unless el
|
|
53
53
|
return error_payload(
|
|
54
54
|
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
@@ -67,7 +67,7 @@ module Browserctl
|
|
|
67
67
|
sel = resolve_selector_from(session, req)
|
|
68
68
|
return sel if sel.is_a?(Hash)
|
|
69
69
|
|
|
70
|
-
el = session.
|
|
70
|
+
el = session.driver.at_css(sel)
|
|
71
71
|
unless el
|
|
72
72
|
return error_payload(
|
|
73
73
|
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
@@ -85,28 +85,26 @@ module Browserctl
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def cmd_dialog_accept(req)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
with_page(req[:name]) do |session|
|
|
89
|
+
text = req[:text]
|
|
90
|
+
id = nil
|
|
91
|
+
id = session.driver.on(:dialog) do |dialog|
|
|
92
|
+
session.driver.off(:dialog, id)
|
|
93
|
+
dialog.accept(text)
|
|
94
|
+
end
|
|
95
|
+
{ ok: true }
|
|
96
96
|
end
|
|
97
|
-
{ ok: true }
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def cmd_dialog_dismiss(req)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
with_page(req[:name]) do |session|
|
|
101
|
+
id = nil
|
|
102
|
+
id = session.driver.on(:dialog) do |dialog|
|
|
103
|
+
session.driver.off(:dialog, id)
|
|
104
|
+
dialog.dismiss
|
|
105
|
+
end
|
|
106
|
+
{ ok: true }
|
|
108
107
|
end
|
|
109
|
-
{ ok: true }
|
|
110
108
|
end
|
|
111
109
|
end
|
|
112
110
|
end
|
|
@@ -19,20 +19,20 @@ module Browserctl
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
with_page(req[:name]) do |session|
|
|
22
|
-
session.
|
|
23
|
-
{ ok: true, url: session.
|
|
22
|
+
session.driver.go_to(req[:url])
|
|
23
|
+
{ ok: true, url: session.driver.current_url, challenge: Detectors.cloudflare?(session.driver) }
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def cmd_wait(req)
|
|
28
28
|
with_page(req[:name]) do |session|
|
|
29
|
-
result = wait_for_selector(session.
|
|
29
|
+
result = wait_for_selector(session.driver, req[:selector], req.fetch(:timeout, 30).to_f)
|
|
30
30
|
result[:error] ? result : { ok: true, selector: req[:selector] }
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def cmd_evaluate(req)
|
|
35
|
-
with_page(req[:name]) { |session| { ok: true, result: session.
|
|
35
|
+
with_page(req[:name]) { |session| { ok: true, result: session.driver.evaluate(req[:expression]) } }
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def cmd_fill(req)
|
|
@@ -40,7 +40,7 @@ module Browserctl
|
|
|
40
40
|
sel = resolve_selector_from(session, req)
|
|
41
41
|
return sel if sel.is_a?(Hash)
|
|
42
42
|
|
|
43
|
-
result = type_into(session.
|
|
43
|
+
result = type_into(session.driver, sel, req[:value])
|
|
44
44
|
enrich_with_recording_metadata(result, session, sel, req)
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -50,7 +50,7 @@ module Browserctl
|
|
|
50
50
|
sel = resolve_selector_from(session, req)
|
|
51
51
|
return sel if sel.is_a?(Hash)
|
|
52
52
|
|
|
53
|
-
result = click_element(session.
|
|
53
|
+
result = click_element(session.driver, sel)
|
|
54
54
|
enrich_with_recording_metadata(result, session, sel, req)
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -69,14 +69,14 @@ module Browserctl
|
|
|
69
69
|
ref: ref,
|
|
70
70
|
fingerprint: fp,
|
|
71
71
|
snapshot_id: session.snapshot_id,
|
|
72
|
-
postcondition_hint: { url: session.
|
|
72
|
+
postcondition_hint: { url: session.driver.current_url }
|
|
73
73
|
)
|
|
74
74
|
enriched[:post_snapshot_digest] = capture_post_snapshot_digest(session) if req[:capture_post_snapshot]
|
|
75
75
|
enriched.compact
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def capture_post_snapshot_digest(session)
|
|
79
|
-
snapshot = @snapshot_builder.call(session.
|
|
79
|
+
snapshot = @snapshot_builder.call(session.driver)
|
|
80
80
|
Browserctl::Replay::SnapshotDiff.digest(snapshot)
|
|
81
81
|
rescue JSON::ParserError, Timeout::Error, Browserctl::Error => e
|
|
82
82
|
Browserctl.logger.debug("post-snapshot digest skipped: #{e.class}: #{e.message}")
|
|
@@ -84,11 +84,11 @@ module Browserctl
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def cmd_url(req)
|
|
87
|
-
with_page(req[:name]) { |session| { ok: true, url: session.
|
|
87
|
+
with_page(req[:name]) { |session| { ok: true, url: session.driver.current_url } }
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def type_into(
|
|
91
|
-
el =
|
|
90
|
+
def type_into(driver, selector, value)
|
|
91
|
+
el = driver.at_css(selector)
|
|
92
92
|
unless el
|
|
93
93
|
return error_payload(
|
|
94
94
|
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
@@ -103,8 +103,8 @@ module Browserctl
|
|
|
103
103
|
{ ok: true }
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
def click_element(
|
|
107
|
-
el =
|
|
106
|
+
def click_element(driver, selector)
|
|
107
|
+
el = driver.at_css(selector)
|
|
108
108
|
unless el
|
|
109
109
|
return error_payload(
|
|
110
110
|
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
@@ -127,10 +127,10 @@ module Browserctl
|
|
|
127
127
|
session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
def wait_for_selector(
|
|
130
|
+
def wait_for_selector(driver, selector, timeout)
|
|
131
131
|
deadline = Time.now + timeout
|
|
132
132
|
loop do
|
|
133
|
-
found =
|
|
133
|
+
found = driver.at_css(selector)
|
|
134
134
|
break { ok: true } if found
|
|
135
135
|
break { error: "wait timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
|
|
136
136
|
|
|
@@ -19,9 +19,9 @@ module Browserctl
|
|
|
19
19
|
# bundle in hand (see PR 18); without them, only the URL signal fires.
|
|
20
20
|
def cmd_auth_check(req)
|
|
21
21
|
with_page(req[:name]) do |session|
|
|
22
|
-
cookies = session.
|
|
22
|
+
cookies = session.driver.cookies_all.values.map(&:to_h) if req[:include_cookies]
|
|
23
23
|
result = Browserctl::Detectors.auth_required(
|
|
24
|
-
session.
|
|
24
|
+
session.driver,
|
|
25
25
|
cookies: cookies,
|
|
26
26
|
suggested_flow: req[:suggested_flow]
|
|
27
27
|
)
|
|
@@ -38,11 +38,11 @@ module Browserctl
|
|
|
38
38
|
|
|
39
39
|
def take_snapshot(session, format, diff)
|
|
40
40
|
nonce = SecureRandom.hex(8)
|
|
41
|
-
challenge = Detectors.cloudflare?(session.
|
|
41
|
+
challenge = Detectors.cloudflare?(session.driver)
|
|
42
42
|
|
|
43
|
-
return { ok: true, html: session.
|
|
43
|
+
return { ok: true, html: session.driver.body, challenge: challenge, nonce: nonce } unless format == "elements"
|
|
44
44
|
|
|
45
|
-
snapshot = @snapshot_builder.call(session.
|
|
45
|
+
snapshot = @snapshot_builder.call(session.driver)
|
|
46
46
|
registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
|
|
47
47
|
fp_index = build_fingerprint_index(snapshot)
|
|
48
48
|
|
|
@@ -84,7 +84,7 @@ module Browserctl
|
|
|
84
84
|
return path if path.is_a?(Hash)
|
|
85
85
|
|
|
86
86
|
FileUtils.mkdir_p(File.dirname(path))
|
|
87
|
-
session.
|
|
87
|
+
session.driver.screenshot(path: path, full: req.fetch(:full, false))
|
|
88
88
|
{ ok: true, path: path }
|
|
89
89
|
end
|
|
90
90
|
end
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../driver/ferrum_page_driver"
|
|
4
|
+
|
|
3
5
|
module Browserctl
|
|
4
6
|
class CommandDispatcher
|
|
5
7
|
module Handlers
|
|
8
|
+
# Page-lifecycle handlers are intrinsically registry-wide: they
|
|
9
|
+
# add to, remove from, or enumerate the `@pages` registry. They
|
|
10
|
+
# legitimately bypass `with_page` and hold `@global_mutex` directly.
|
|
6
11
|
module PageLifecycle
|
|
7
12
|
private
|
|
8
13
|
|
|
9
14
|
def cmd_page_open(req)
|
|
15
|
+
# registry-wide: adds an entry to @pages, needs @global_mutex.
|
|
10
16
|
session = @global_mutex.synchronize do
|
|
11
|
-
@pages[req[:name]] ||= PageSession.new(@driver.create_page)
|
|
17
|
+
@pages[req[:name]] ||= PageSession.new(Browserctl::Driver::FerrumPageDriver.new(@driver.create_page))
|
|
12
18
|
end
|
|
13
|
-
session.
|
|
19
|
+
session.driver.go_to(req[:url]) if req[:url]
|
|
14
20
|
{ ok: true, name: req[:name] }
|
|
15
21
|
end
|
|
16
22
|
|
|
17
23
|
def cmd_page_close(req)
|
|
24
|
+
# registry-wide: removes an entry from @pages, needs @global_mutex.
|
|
18
25
|
session = @global_mutex.synchronize { @pages.delete(req[:name]) }
|
|
19
|
-
session&.
|
|
26
|
+
session&.driver&.close
|
|
20
27
|
{ ok: true }
|
|
21
28
|
end
|
|
22
29
|
|
|
23
30
|
def cmd_page_list(_req)
|
|
31
|
+
# registry-wide read: enumerates @pages keys (no per-page state).
|
|
24
32
|
{ pages: @global_mutex.synchronize { @pages.keys } }
|
|
25
33
|
end
|
|
26
34
|
|
|
@@ -28,7 +36,7 @@ module Browserctl
|
|
|
28
36
|
return { error: "page focus requires headed mode — start browserd with --headed" } unless @driver.headed?
|
|
29
37
|
|
|
30
38
|
with_page(req[:name]) do |session|
|
|
31
|
-
session.
|
|
39
|
+
session.driver.activate
|
|
32
40
|
{ ok: true }
|
|
33
41
|
end
|
|
34
42
|
end
|