browserctl 0.10.0 → 0.12.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 +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Replay
|
|
5
|
+
# Per-page replay context carried by PageProxy during a workflow run
|
|
6
|
+
# generated from a recording.
|
|
7
|
+
#
|
|
8
|
+
# Holds the recorded fingerprint for each selector that the workflow
|
|
9
|
+
# interacts with. When a selector-driven command fails with
|
|
10
|
+
# selector_not_found at replay time, the proxy looks up the fingerprint
|
|
11
|
+
# here and asks FingerprintMatcher to find a candidate in the live
|
|
12
|
+
# snapshot. The matched element's stable ref is then re-used to retry
|
|
13
|
+
# the original command.
|
|
14
|
+
#
|
|
15
|
+
# Drift events (rematches, threshold misses) are accumulated on the
|
|
16
|
+
# context so the surrounding workflow runner can render them into a
|
|
17
|
+
# drift report at end-of-run.
|
|
18
|
+
class Context
|
|
19
|
+
DriftEvent = Struct.new(:command, :selector, :matched_ref, :score, :reason, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
attr_reader :drift_events
|
|
22
|
+
|
|
23
|
+
def initialize(fingerprints: {})
|
|
24
|
+
@fingerprints = fingerprints
|
|
25
|
+
@drift_events = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fingerprint_for(selector)
|
|
29
|
+
@fingerprints[selector]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record(command:, selector:, matched_ref: nil, score: nil, reason: nil)
|
|
33
|
+
@drift_events << DriftEvent.new(
|
|
34
|
+
command: command, selector: selector,
|
|
35
|
+
matched_ref: matched_ref, score: score, reason: reason
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Replay
|
|
5
|
+
# Scores candidate snapshot entries against a recorded fingerprint and
|
|
6
|
+
# returns the best match above a configurable threshold.
|
|
7
|
+
#
|
|
8
|
+
# Inputs are the wire-shape fingerprints emitted by Snapshot::Fingerprint:
|
|
9
|
+
# { text:, role:, neighbors: [...], position: { index:, depth: } }
|
|
10
|
+
#
|
|
11
|
+
# Score is a weighted sum in [0.0, 1.0]:
|
|
12
|
+
# text 0.40 (exact match; case-insensitive)
|
|
13
|
+
# role 0.20 (exact match)
|
|
14
|
+
# neighbors 0.25 (Jaccard over the neighbor sets)
|
|
15
|
+
# position 0.15 (proximity in (index, depth) space)
|
|
16
|
+
#
|
|
17
|
+
# Defaults reflect the v0.11 acceptance bar: text + role together (0.60)
|
|
18
|
+
# are enough to clear the default threshold, so a renamed neighbor or a
|
|
19
|
+
# shifted index doesn't break replay.
|
|
20
|
+
class FingerprintMatcher
|
|
21
|
+
DEFAULT_THRESHOLD = 0.6
|
|
22
|
+
WEIGHTS = { text: 0.40, role: 0.20, neighbors: 0.25, position: 0.15 }.freeze
|
|
23
|
+
|
|
24
|
+
Match = Struct.new(:candidate, :score, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
def initialize(threshold: DEFAULT_THRESHOLD, weights: WEIGHTS)
|
|
27
|
+
@threshold = threshold
|
|
28
|
+
@weights = weights
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the highest-scoring candidate entry above the threshold, or
|
|
32
|
+
# nil if no candidate qualifies. `candidates` must be an array of
|
|
33
|
+
# snapshot entries (hashes with a :fingerprint key). The returned
|
|
34
|
+
# Match wraps the candidate hash and the numeric score.
|
|
35
|
+
def best(target_fp, candidates)
|
|
36
|
+
scored = candidates
|
|
37
|
+
.map { |c| Match.new(candidate: c, score: score(target_fp, c[:fingerprint])) }
|
|
38
|
+
.sort_by { |m| -m.score }
|
|
39
|
+
|
|
40
|
+
winner = scored.first
|
|
41
|
+
return nil unless winner && winner.score >= @threshold
|
|
42
|
+
|
|
43
|
+
winner
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def score(target, candidate)
|
|
47
|
+
return 0.0 unless target && candidate
|
|
48
|
+
|
|
49
|
+
(@weights[:text] * text_score(target[:text], candidate[:text])) +
|
|
50
|
+
(@weights[:role] * bool_score(target[:role] == candidate[:role])) +
|
|
51
|
+
(@weights[:neighbors] * jaccard(target[:neighbors], candidate[:neighbors])) +
|
|
52
|
+
(@weights[:position] * position_score(target[:position], candidate[:position]))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def text_score(target, candidate)
|
|
58
|
+
return 0.0 if target.nil? || candidate.nil? || target.empty? || candidate.empty?
|
|
59
|
+
|
|
60
|
+
target.downcase.strip == candidate.downcase.strip ? 1.0 : 0.0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bool_score(flag) = flag ? 1.0 : 0.0
|
|
64
|
+
|
|
65
|
+
def jaccard(target, candidate)
|
|
66
|
+
target = Array(target)
|
|
67
|
+
candidate = Array(candidate)
|
|
68
|
+
return 1.0 if target.empty? && candidate.empty?
|
|
69
|
+
return 0.0 if target.empty? || candidate.empty?
|
|
70
|
+
|
|
71
|
+
inter = (target & candidate).size
|
|
72
|
+
union = (target | candidate).size
|
|
73
|
+
union.zero? ? 0.0 : inter.to_f / union
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def position_score(target, candidate)
|
|
77
|
+
return 0.0 unless target && candidate
|
|
78
|
+
|
|
79
|
+
idx_d = (target[:index].to_i - candidate[:index].to_i).abs
|
|
80
|
+
depth_d = (target[:depth].to_i - candidate[:depth].to_i).abs
|
|
81
|
+
# Soft falloff: 1.0 when identical, ~0 once they're 4+ apart in either axis.
|
|
82
|
+
[1.0 - ((idx_d + depth_d) / 8.0), 0.0].max
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Replay
|
|
7
|
+
# Stable digest + element-set comparison for post-step snapshots.
|
|
8
|
+
#
|
|
9
|
+
# The digest is intentionally cheap and stable across cosmetic DOM noise:
|
|
10
|
+
# only the (selector, role, tag) triples drive the hash, sorted to remove
|
|
11
|
+
# ordering effects. That's enough to flag structural drift (a step that
|
|
12
|
+
# used to land on /dashboard now lands on /login) without flapping on
|
|
13
|
+
# every reflow or class rename.
|
|
14
|
+
module SnapshotDiff
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def digest(snapshot)
|
|
18
|
+
return nil if snapshot.nil?
|
|
19
|
+
|
|
20
|
+
keys = Array(snapshot).map { |el| identity_tuple(el) }.compact.sort
|
|
21
|
+
Digest::SHA1.hexdigest(keys.join("\n"))[0, 16]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns { added: [...], removed: [...] } of element selectors that
|
|
25
|
+
# differ between two snapshots. Empty arrays mean structurally identical.
|
|
26
|
+
def compare(prev, current)
|
|
27
|
+
prev_set = element_set(prev)
|
|
28
|
+
current_set = element_set(current)
|
|
29
|
+
{
|
|
30
|
+
added: (current_set - prev_set).sort,
|
|
31
|
+
removed: (prev_set - current_set).sort
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def identity_tuple(entry)
|
|
36
|
+
return nil unless entry.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
sel = entry[:selector] || entry["selector"]
|
|
39
|
+
role = entry[:role] || entry["role"]
|
|
40
|
+
tag = entry[:tag] || entry["tag"]
|
|
41
|
+
return nil unless sel
|
|
42
|
+
|
|
43
|
+
"#{sel}|#{role}|#{tag}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def element_set(snapshot)
|
|
47
|
+
Array(snapshot).map { |entry| entry[:selector] || entry["selector"] }.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../constants"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module Replay
|
|
9
|
+
# Append-only JSONL log of replay drift events for offline analysis.
|
|
10
|
+
# Local-only; nothing is uploaded. One line per event.
|
|
11
|
+
module Telemetry
|
|
12
|
+
LOG_BASENAME = "replay_drift.jsonl"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def log_path
|
|
17
|
+
File.join(Browserctl::BROWSERCTL_DIR, LOG_BASENAME)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Write each drift event from a Replay::Context as its own JSONL line.
|
|
21
|
+
# @param ctx [Browserctl::Replay::Context, nil]
|
|
22
|
+
# @param workflow [String] workflow name for cross-reference
|
|
23
|
+
# @param path [String] override the destination (testing)
|
|
24
|
+
# @return [Integer] number of events written
|
|
25
|
+
def emit(ctx, workflow:, path: log_path)
|
|
26
|
+
events = ctx&.drift_events
|
|
27
|
+
return 0 if events.nil? || events.empty?
|
|
28
|
+
|
|
29
|
+
ensure_log_file(path)
|
|
30
|
+
ts = Time.now.utc.iso8601
|
|
31
|
+
File.open(path, "a") do |f|
|
|
32
|
+
events.each do |e|
|
|
33
|
+
f.puts JSON.generate(
|
|
34
|
+
event: "replay_drift",
|
|
35
|
+
ts: ts,
|
|
36
|
+
workflow: workflow,
|
|
37
|
+
command: e.command.to_s,
|
|
38
|
+
selector: e.selector,
|
|
39
|
+
matched_ref: e.matched_ref,
|
|
40
|
+
score: e.score,
|
|
41
|
+
reason: e.reason
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
events.size
|
|
46
|
+
rescue SystemCallError, IOError
|
|
47
|
+
# Telemetry must never break a run.
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ensure_log_file(path)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
53
|
+
return if File.exist?(path)
|
|
54
|
+
|
|
55
|
+
FileUtils.touch(path)
|
|
56
|
+
File.chmod(0o600, path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Browserctl
|
|
8
|
+
# Enforces that any explicit `code:` keyword passed to a `raise` of a
|
|
9
|
+
# `Browserctl::*` error refers to a constant from
|
|
10
|
+
# `Browserctl::Error::Codes` rather than a free-form string literal.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses with their own `default_code` are trusted (the cop does not
|
|
13
|
+
# try to statically resolve `default_code` across files); the contract
|
|
14
|
+
# is enforced by the unit test suite. This cop's job is to catch the
|
|
15
|
+
# specific failure mode of inlining a stale snake_case code at the
|
|
16
|
+
# raise site and bypassing the canonical enum.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # bad — string literal that isn't a Codes constant
|
|
20
|
+
# raise Browserctl::Error, "state expired", code: "state_expired"
|
|
21
|
+
#
|
|
22
|
+
# # good — Codes constant reference
|
|
23
|
+
# raise Browserctl::Error, "state expired",
|
|
24
|
+
# code: Browserctl::Error::Codes::STATE_EXPIRED
|
|
25
|
+
#
|
|
26
|
+
# # good — typed subclass relies on its own default_code
|
|
27
|
+
# raise Browserctl::SelectorNotFound, "no such selector"
|
|
28
|
+
#
|
|
29
|
+
# The pattern is intentionally narrow. The full default_code-vs-Codes
|
|
30
|
+
# reconciliation lives in `lib/browserctl/errors.rb` and is covered by
|
|
31
|
+
# `spec/unit/errors_spec.rb`.
|
|
32
|
+
class TypedError < RuboCop::Cop::Base
|
|
33
|
+
MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
|
|
34
|
+
"got string literal %<value>p. See lib/browserctl/error/codes.rb."
|
|
35
|
+
|
|
36
|
+
VALID_CODES = %w[
|
|
37
|
+
AUTH_REQUIRED
|
|
38
|
+
SELECTOR_NOT_FOUND
|
|
39
|
+
STATE_EXPIRED
|
|
40
|
+
SECRET_RESOLUTION_FAILED
|
|
41
|
+
DAEMON_UNREACHABLE
|
|
42
|
+
PROTOCOL_MISMATCH
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
|
|
46
|
+
# `code:` value node.
|
|
47
|
+
def_node_matcher :browserctl_raise_with_code, <<~PATTERN
|
|
48
|
+
(send nil? :raise
|
|
49
|
+
(const (const nil? :Browserctl) _)
|
|
50
|
+
...
|
|
51
|
+
(hash <(pair (sym :code) $_) ...>))
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def on_send(node)
|
|
55
|
+
return unless node.method?(:raise)
|
|
56
|
+
|
|
57
|
+
browserctl_raise_with_code(node) do |code_value|
|
|
58
|
+
next unless code_value.str_type?
|
|
59
|
+
|
|
60
|
+
value = code_value.value
|
|
61
|
+
next if VALID_CODES.include?(value)
|
|
62
|
+
|
|
63
|
+
add_offense(code_value, message: format(MSG, value: value))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "workflow"
|
|
5
|
+
require_relative "workflow/promotion_ledger"
|
|
5
6
|
require_relative "client"
|
|
7
|
+
require_relative "replay/telemetry"
|
|
6
8
|
|
|
7
9
|
module Browserctl
|
|
8
10
|
class Runner
|
|
@@ -14,13 +16,27 @@ module Browserctl
|
|
|
14
16
|
# Runs a named workflow with the given parameters.
|
|
15
17
|
# @param name [String] workflow name (must match /\A[a-zA-Z0-9_-]+\z/)
|
|
16
18
|
# @param params [Hash] keyword arguments passed to the workflow
|
|
17
|
-
# @
|
|
19
|
+
# @param check [Boolean] when true, attaches a Replay::Context, renders
|
|
20
|
+
# a drift report after the run, and signals drift via exit code 2.
|
|
21
|
+
# @return [Symbol] :clean (all ok, no drift), :drift (all ok, drift seen), :fail (any step failed)
|
|
18
22
|
# @raise [WorkflowError] if the name is invalid or a step fails
|
|
19
|
-
def run_workflow(name, **params)
|
|
23
|
+
def run_workflow(name, check: false, **params)
|
|
20
24
|
defn = fetch_workflow(name)
|
|
21
|
-
|
|
25
|
+
ctx = check ? Browserctl::Replay::Context.new : nil
|
|
26
|
+
begin
|
|
27
|
+
results = defn.call(params, Client.new, replay_context: ctx)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
Browserctl::Workflow::PromotionLedger.record(workflow: name.to_s, verdict: :fail) if check
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
22
32
|
print_results(results)
|
|
23
|
-
results
|
|
33
|
+
v = verdict(results, ctx)
|
|
34
|
+
if check
|
|
35
|
+
print_drift_report(ctx)
|
|
36
|
+
Browserctl::Replay::Telemetry.emit(ctx, workflow: name.to_s)
|
|
37
|
+
Browserctl::Workflow::PromotionLedger.record(workflow: name.to_s, verdict: v)
|
|
38
|
+
end
|
|
39
|
+
v
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
# Lists all registered workflows from the standard search paths.
|
|
@@ -41,7 +57,7 @@ module Browserctl
|
|
|
41
57
|
SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
42
58
|
|
|
43
59
|
def self.load_params_file(path)
|
|
44
|
-
raise "params file not found: #{path}" unless File.exist?(path)
|
|
60
|
+
raise Browserctl::WorkflowError, "params file not found: #{path}" unless File.exist?(path)
|
|
45
61
|
|
|
46
62
|
case File.extname(path).downcase
|
|
47
63
|
when ".yml", ".yaml"
|
|
@@ -50,12 +66,12 @@ module Browserctl
|
|
|
50
66
|
when ".json"
|
|
51
67
|
JSON.parse(File.read(path), symbolize_names: true)
|
|
52
68
|
else
|
|
53
|
-
raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
69
|
+
raise Browserctl::WorkflowError, "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
54
70
|
end
|
|
55
71
|
rescue Psych::SyntaxError => e
|
|
56
|
-
raise "invalid YAML in #{path}: #{e.message}"
|
|
72
|
+
raise Browserctl::WorkflowError, "invalid YAML in #{path}: #{e.message}"
|
|
57
73
|
rescue JSON::ParserError => e
|
|
58
|
-
raise "invalid JSON in #{path}: #{e.message}"
|
|
74
|
+
raise Browserctl::WorkflowError, "invalid JSON in #{path}: #{e.message}"
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
private
|
|
@@ -79,7 +95,10 @@ module Browserctl
|
|
|
79
95
|
return if Browserctl.lookup_workflow(name.to_s)
|
|
80
96
|
|
|
81
97
|
path = workflow_path(name)
|
|
82
|
-
|
|
98
|
+
return unless path
|
|
99
|
+
|
|
100
|
+
Browserctl.verify_workflow_format_version!(path)
|
|
101
|
+
load path
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def workflow_path(name)
|
|
@@ -95,7 +114,10 @@ module Browserctl
|
|
|
95
114
|
|
|
96
115
|
def load_from_dir(dir)
|
|
97
116
|
Dir.glob("#{dir}/*.rb").each do |f|
|
|
98
|
-
|
|
117
|
+
next if $LOADED_FEATURES.include?(f)
|
|
118
|
+
|
|
119
|
+
Browserctl.verify_workflow_format_version!(f)
|
|
120
|
+
load f
|
|
99
121
|
end
|
|
100
122
|
end
|
|
101
123
|
|
|
@@ -109,6 +131,24 @@ module Browserctl
|
|
|
109
131
|
$stdout.puts " #{label} #{msg}"
|
|
110
132
|
end
|
|
111
133
|
|
|
134
|
+
def print_drift_report(ctx)
|
|
135
|
+
events = ctx&.drift_events || []
|
|
136
|
+
report = {
|
|
137
|
+
drift: events.any?,
|
|
138
|
+
rematches: events.count { |e| e.reason == "rematch" },
|
|
139
|
+
unresolved: events.count { |e| e.reason == "no candidate above threshold" },
|
|
140
|
+
events: events.map(&:to_h)
|
|
141
|
+
}
|
|
142
|
+
$stdout.puts JSON.pretty_generate(report)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def verdict(results, ctx)
|
|
146
|
+
return :fail unless results.all?(&:ok)
|
|
147
|
+
return :drift if ctx&.drift_events&.any?
|
|
148
|
+
|
|
149
|
+
:clean
|
|
150
|
+
end
|
|
151
|
+
|
|
112
152
|
def format_params(defn)
|
|
113
153
|
defn.param_defs.transform_values do |p|
|
|
114
154
|
entry = { required: p.required, secret: p.secret, default: p.default }
|
|
@@ -4,8 +4,9 @@ require_relative "errors"
|
|
|
4
4
|
|
|
5
5
|
module Browserctl
|
|
6
6
|
class SecretResolverRegistry
|
|
7
|
-
@mutex
|
|
8
|
-
@registry
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@registry = {}
|
|
9
|
+
@resolved_values = []
|
|
9
10
|
|
|
10
11
|
def self.register(resolver_class)
|
|
11
12
|
instance = resolver_class.new
|
|
@@ -25,7 +26,9 @@ module Browserctl
|
|
|
25
26
|
raise SecretResolverError, msg
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
resolver.resolve(reference)
|
|
29
|
+
value = resolver.resolve(reference)
|
|
30
|
+
record_resolved_value(value)
|
|
31
|
+
value
|
|
29
32
|
rescue SecretResolverError
|
|
30
33
|
raise
|
|
31
34
|
rescue StandardError => e
|
|
@@ -36,8 +39,24 @@ module Browserctl
|
|
|
36
39
|
@mutex.synchronize { @registry.key?(scheme) }
|
|
37
40
|
end
|
|
38
41
|
|
|
42
|
+
# In-memory record of values resolved during this process. Used by the
|
|
43
|
+
# Redactor so trace output never leaks values that flowed through the
|
|
44
|
+
# registry. Never persisted.
|
|
45
|
+
def self.resolved_values
|
|
46
|
+
@mutex.synchronize { @resolved_values.dup }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.record_resolved_value(value)
|
|
50
|
+
return unless value.is_a?(String) && !value.empty?
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize { @resolved_values << value unless @resolved_values.include?(value) }
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
def self.reset!
|
|
40
|
-
@mutex.synchronize
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@registry.clear
|
|
58
|
+
@resolved_values.clear
|
|
59
|
+
end
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
4
|
require_relative "page_session"
|
|
5
|
+
require_relative "handlers/error_payload"
|
|
5
6
|
require_relative "handlers/page_lifecycle"
|
|
6
7
|
require_relative "handlers/navigation"
|
|
7
8
|
require_relative "handlers/observation"
|
|
@@ -11,12 +12,16 @@ require_relative "handlers/devtools"
|
|
|
11
12
|
require_relative "handlers/daemon_control"
|
|
12
13
|
require_relative "handlers/storage"
|
|
13
14
|
require_relative "handlers/session"
|
|
15
|
+
require_relative "handlers/state"
|
|
14
16
|
require_relative "handlers/interaction"
|
|
15
17
|
require_relative "../detectors"
|
|
16
18
|
require_relative "../policy"
|
|
19
|
+
require_relative "../errors"
|
|
20
|
+
require_relative "../replay/snapshot_diff"
|
|
17
21
|
|
|
18
22
|
module Browserctl
|
|
19
23
|
class CommandDispatcher
|
|
24
|
+
include Handlers::ErrorPayload
|
|
20
25
|
include Handlers::PageLifecycle
|
|
21
26
|
include Handlers::Navigation
|
|
22
27
|
include Handlers::Observation
|
|
@@ -26,6 +31,7 @@ module Browserctl
|
|
|
26
31
|
include Handlers::DaemonControl
|
|
27
32
|
include Handlers::Storage
|
|
28
33
|
include Handlers::Session
|
|
34
|
+
include Handlers::State
|
|
29
35
|
include Handlers::Interaction
|
|
30
36
|
|
|
31
37
|
COMMAND_MAP = {
|
|
@@ -36,6 +42,7 @@ module Browserctl
|
|
|
36
42
|
"navigate" => :cmd_navigate,
|
|
37
43
|
"wait" => :cmd_wait,
|
|
38
44
|
"snapshot" => :cmd_snapshot,
|
|
45
|
+
"auth_check" => :cmd_auth_check,
|
|
39
46
|
"evaluate" => :cmd_evaluate,
|
|
40
47
|
"fill" => :cmd_fill,
|
|
41
48
|
"click" => :cmd_click,
|
|
@@ -66,7 +73,12 @@ module Browserctl
|
|
|
66
73
|
"session_save" => :cmd_session_save,
|
|
67
74
|
"session_load" => :cmd_session_load,
|
|
68
75
|
"session_list" => :cmd_session_list,
|
|
69
|
-
"session_delete" => :cmd_session_delete
|
|
76
|
+
"session_delete" => :cmd_session_delete,
|
|
77
|
+
"state_save" => :cmd_state_save,
|
|
78
|
+
"state_load" => :cmd_state_load,
|
|
79
|
+
"state_list" => :cmd_state_list,
|
|
80
|
+
"state_info" => :cmd_state_info,
|
|
81
|
+
"state_delete" => :cmd_state_delete
|
|
70
82
|
}.freeze
|
|
71
83
|
|
|
72
84
|
SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
|
|
@@ -21,7 +21,11 @@ module Browserctl
|
|
|
21
21
|
def cmd_fetch(req)
|
|
22
22
|
key = req[:key].to_s
|
|
23
23
|
found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
|
|
24
|
-
found ||
|
|
24
|
+
found || error_payload(
|
|
25
|
+
code: Browserctl::Error::Codes::KEY_NOT_FOUND,
|
|
26
|
+
message: "key '#{key}' not found",
|
|
27
|
+
context: { key: key }
|
|
28
|
+
)
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class CommandDispatcher
|
|
7
|
+
module Handlers
|
|
8
|
+
# Centralised structured-error builder for daemon JSON-RPC responses.
|
|
9
|
+
# Each handler returns `{ error:, code:, context:, suggested_action: }`
|
|
10
|
+
# for any failure carrying a stable {Browserctl::Error::Codes} code.
|
|
11
|
+
module ErrorPayload
|
|
12
|
+
# @param code [String] a SCREAMING_SNAKE code from {Codes}
|
|
13
|
+
# @param message [String] human-readable error
|
|
14
|
+
# @param context [Hash] free-form structured fields (selector, path, ...)
|
|
15
|
+
# @return [Hash{Symbol => Object}]
|
|
16
|
+
def error_payload(code:, message:, context: {})
|
|
17
|
+
{
|
|
18
|
+
error: message,
|
|
19
|
+
code: code,
|
|
20
|
+
context: context,
|
|
21
|
+
suggested_action: Browserctl::Error::SuggestedActions.for(code)
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -27,7 +27,13 @@ module Browserctl
|
|
|
27
27
|
"return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
|
|
28
28
|
"})(#{sel.to_json})"
|
|
29
29
|
)
|
|
30
|
-
|
|
30
|
+
unless coords
|
|
31
|
+
return error_payload(
|
|
32
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
33
|
+
message: "selector not found: #{sel}",
|
|
34
|
+
context: { selector: sel }
|
|
35
|
+
)
|
|
36
|
+
end
|
|
31
37
|
|
|
32
38
|
session.page.mouse.move(x: coords["x"], y: coords["y"])
|
|
33
39
|
{ ok: true }
|
|
@@ -43,7 +49,13 @@ module Browserctl
|
|
|
43
49
|
return sel if sel.is_a?(Hash)
|
|
44
50
|
|
|
45
51
|
el = session.page.at_css(sel)
|
|
46
|
-
|
|
52
|
+
unless el
|
|
53
|
+
return error_payload(
|
|
54
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
55
|
+
message: "selector not found: #{sel}",
|
|
56
|
+
context: { selector: sel }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
47
59
|
|
|
48
60
|
el.select_file(path)
|
|
49
61
|
{ ok: true }
|
|
@@ -56,7 +68,13 @@ module Browserctl
|
|
|
56
68
|
return sel if sel.is_a?(Hash)
|
|
57
69
|
|
|
58
70
|
el = session.page.at_css(sel)
|
|
59
|
-
|
|
71
|
+
unless el
|
|
72
|
+
return error_payload(
|
|
73
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
74
|
+
message: "selector not found: #{sel}",
|
|
75
|
+
context: { selector: sel }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
60
78
|
|
|
61
79
|
el.evaluate(
|
|
62
80
|
"this.value = #{req[:value].to_json}; " \
|