browserctl 0.10.0 → 0.11.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 +38 -0
- data/README.md +1 -1
- data/bin/browserctl +45 -4
- data/lib/browserctl/client.rb +47 -3
- data/lib/browserctl/commands/cli_output.rb +16 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +1 -1
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/errors.rb +30 -0
- 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/recording.rb +212 -26
- 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/runner.rb +38 -4
- data/lib/browserctl/server/command_dispatcher.rb +10 -1
- data/lib/browserctl/server/handlers/interaction.rb +3 -3
- data/lib/browserctl/server/handlers/navigation.rb +33 -4
- 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/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 +242 -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 +180 -16
- metadata +31 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../constants"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Workflow
|
|
8
|
+
# Renders a `Browserctl.flow` definition that wraps a promoted workflow.
|
|
9
|
+
# The flow becomes a globally-registered, parameterised handle that runs
|
|
10
|
+
# the underlying workflow via `Runner#run_workflow`. Params are inferred
|
|
11
|
+
# from the workflow's `param_defs` so callers see the same surface area
|
|
12
|
+
# they would on the workflow itself.
|
|
13
|
+
#
|
|
14
|
+
# Wrapping (rather than translating step-by-step) keeps the workflow as
|
|
15
|
+
# the single source of truth: edits to the workflow file flow through
|
|
16
|
+
# to the wrapper without regeneration.
|
|
17
|
+
module FlowWrapper
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def target_dir
|
|
21
|
+
File.join(Browserctl::BROWSERCTL_DIR, "flows")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def target_path(name)
|
|
25
|
+
File.join(target_dir, "#{name}.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param defn [Browserctl::WorkflowDefinition]
|
|
29
|
+
# @return [String] Ruby source for a flow file
|
|
30
|
+
def render(defn)
|
|
31
|
+
params = defn.param_defs.values.map { |p| render_param(p) }.join("\n")
|
|
32
|
+
desc = defn.description || "Promoted from workflow '#{defn.name}'"
|
|
33
|
+
<<~RUBY
|
|
34
|
+
# frozen_string_literal: true
|
|
35
|
+
|
|
36
|
+
require "browserctl/flow"
|
|
37
|
+
require "browserctl/runner"
|
|
38
|
+
|
|
39
|
+
# Auto-generated flow wrapper for workflow '#{defn.name}'.
|
|
40
|
+
# Edit the underlying workflow file rather than this wrapper.
|
|
41
|
+
Browserctl.flow(#{defn.name.inspect}) do
|
|
42
|
+
version "1.0.0"
|
|
43
|
+
requires_browserctl "0.11.0"
|
|
44
|
+
desc #{desc.inspect}
|
|
45
|
+
|
|
46
|
+
#{params.gsub(/^/, ' ') unless params.empty?}
|
|
47
|
+
|
|
48
|
+
step("run workflow #{defn.name}") do
|
|
49
|
+
Browserctl::Runner.new.run_workflow(#{defn.name.inspect}, **params)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
RUBY
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param defn [Browserctl::WorkflowDefinition]
|
|
56
|
+
# @param overwrite [Boolean]
|
|
57
|
+
# @param dir [String, nil] override target dir (testing)
|
|
58
|
+
# @return [String] path written
|
|
59
|
+
def write(defn, overwrite: true, dir: nil)
|
|
60
|
+
path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
|
|
61
|
+
if File.exist?(path) && !overwrite
|
|
62
|
+
raise "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
66
|
+
File.write(path, render(defn))
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_param(param)
|
|
71
|
+
opts = []
|
|
72
|
+
opts << "required: true" if param.required
|
|
73
|
+
opts << "secret: true" if param.secret && !param.secret_ref
|
|
74
|
+
opts << "secret_ref: #{param.secret_ref.inspect}" if param.secret_ref
|
|
75
|
+
opts << "default: #{param.default.inspect}" unless param.default.nil?
|
|
76
|
+
suffix = opts.empty? ? "" : ", #{opts.join(', ')}"
|
|
77
|
+
"param :#{param.name}#{suffix}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../constants"
|
|
5
|
+
require_relative "promotion_ledger"
|
|
6
|
+
require_relative "flow_wrapper"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Workflow
|
|
10
|
+
# Promotes a workflow file from the project-local `.browserctl/workflows/`
|
|
11
|
+
# directory to the user-global `~/.browserctl/workflows/` directory, where
|
|
12
|
+
# it is invocable from any project.
|
|
13
|
+
#
|
|
14
|
+
# Promotion is gated by `PromotionLedger.clean_streak`: a workflow must
|
|
15
|
+
# have at least `threshold` consecutive clean `--check` runs before it
|
|
16
|
+
# can be promoted. `--force` overrides the gate.
|
|
17
|
+
module Promoter
|
|
18
|
+
class IneligibleError < StandardError
|
|
19
|
+
attr_reader :streak, :threshold
|
|
20
|
+
|
|
21
|
+
def initialize(workflow:, streak:, threshold:)
|
|
22
|
+
@streak = streak
|
|
23
|
+
@threshold = threshold
|
|
24
|
+
super(
|
|
25
|
+
"workflow '#{workflow}' has #{streak} clean --check run(s); " \
|
|
26
|
+
"needs #{threshold}. Run `browserctl workflow run #{workflow} --check` " \
|
|
27
|
+
"until clean, or pass --force to override."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class NotFoundError < StandardError; end
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
DEFAULT_SOURCE_DIR = ".browserctl/workflows"
|
|
37
|
+
|
|
38
|
+
def target_dir
|
|
39
|
+
File.join(Browserctl::BROWSERCTL_DIR, "workflows")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source_path(workflow, source_dir: DEFAULT_SOURCE_DIR)
|
|
43
|
+
File.join(source_dir, "#{workflow}.rb")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def target_path(workflow)
|
|
47
|
+
File.join(target_dir, "#{workflow}.rb")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param workflow [String]
|
|
51
|
+
# @param force [Boolean]
|
|
52
|
+
# @param threshold [Integer]
|
|
53
|
+
# @param source_dir [String] override the source directory (testing)
|
|
54
|
+
# @param ledger_path [String] override the ledger path (testing)
|
|
55
|
+
# @return [Hash] `{ workflow:, source:, target:, streak:, threshold:, forced: }`
|
|
56
|
+
def promote(workflow:, force: false, threshold: PromotionLedger::DEFAULT_THRESHOLD, # rubocop:disable Metrics/ParameterLists
|
|
57
|
+
as_flow: false, source_dir: DEFAULT_SOURCE_DIR,
|
|
58
|
+
ledger_path: PromotionLedger.ledger_path, flow_dir: nil)
|
|
59
|
+
src = source_path(workflow, source_dir: source_dir)
|
|
60
|
+
raise NotFoundError, "workflow file not found: #{src}" unless File.exist?(src)
|
|
61
|
+
|
|
62
|
+
streak = PromotionLedger.clean_streak(workflow: workflow, path: ledger_path)
|
|
63
|
+
unless force || streak >= threshold
|
|
64
|
+
raise IneligibleError.new(workflow: workflow, streak: streak, threshold: threshold)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
dst = target_path(workflow)
|
|
68
|
+
FileUtils.mkdir_p(File.dirname(dst))
|
|
69
|
+
FileUtils.cp(src, dst)
|
|
70
|
+
|
|
71
|
+
result = {
|
|
72
|
+
workflow: workflow,
|
|
73
|
+
source: src,
|
|
74
|
+
target: dst,
|
|
75
|
+
streak: streak,
|
|
76
|
+
threshold: threshold,
|
|
77
|
+
forced: force && streak < threshold
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
result[:flow] = wrap_as_flow(workflow, dst, flow_dir) if as_flow
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Loads the just-promoted workflow file, infers params from its
|
|
85
|
+
# WorkflowDefinition, and writes a flow wrapper at `flow_dir`
|
|
86
|
+
# (defaults to `~/.browserctl/flows/<name>.rb`).
|
|
87
|
+
def wrap_as_flow(workflow, workflow_path, flow_dir)
|
|
88
|
+
load workflow_path
|
|
89
|
+
defn = Browserctl.lookup_workflow(workflow.to_s) or
|
|
90
|
+
raise NotFoundError, "workflow '#{workflow}' did not register after load"
|
|
91
|
+
|
|
92
|
+
FlowWrapper.write(defn, overwrite: true, dir: flow_dir)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../constants"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Workflow
|
|
10
|
+
# Append-only JSONL ledger of `workflow run --check` outcomes per workflow.
|
|
11
|
+
# Used as the gate for `workflow promote`: only workflows with a sufficient
|
|
12
|
+
# streak of clean runs are eligible for promotion to `~/.browserctl/workflows/`.
|
|
13
|
+
#
|
|
14
|
+
# Record schema (one JSONL line):
|
|
15
|
+
# { "ts": "2026-05-10T12:00:00Z", "workflow": "name", "verdict": "clean" }
|
|
16
|
+
module PromotionLedger
|
|
17
|
+
LEDGER_BASENAME = "check_ledger.jsonl"
|
|
18
|
+
DEFAULT_THRESHOLD = 3
|
|
19
|
+
VALID_VERDICTS = %i[clean drift fail].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def ledger_path
|
|
24
|
+
File.join(Browserctl::BROWSERCTL_DIR, LEDGER_BASENAME)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Append a verdict for a workflow run.
|
|
28
|
+
# @param workflow [String]
|
|
29
|
+
# @param verdict [Symbol] :clean, :drift, or :fail
|
|
30
|
+
# @param path [String] override (testing)
|
|
31
|
+
def record(workflow:, verdict:, path: ledger_path, at: Time.now.utc)
|
|
32
|
+
return unless VALID_VERDICTS.include?(verdict)
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
35
|
+
File.open(path, "a") do |f|
|
|
36
|
+
f.puts JSON.generate(
|
|
37
|
+
ts: at.iso8601,
|
|
38
|
+
workflow: workflow.to_s,
|
|
39
|
+
verdict: verdict.to_s
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Count the trailing streak of :clean verdicts for a workflow.
|
|
45
|
+
# A non-clean verdict resets the streak. Drift and fail both break it
|
|
46
|
+
# — the gate is intentionally strict; users can override with --force.
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def clean_streak(workflow:, path: ledger_path)
|
|
49
|
+
return 0 unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
streak = 0
|
|
52
|
+
File.foreach(path) do |line|
|
|
53
|
+
entry = parse(line) or next
|
|
54
|
+
next unless entry["workflow"] == workflow.to_s
|
|
55
|
+
|
|
56
|
+
if entry["verdict"] == "clean"
|
|
57
|
+
streak += 1
|
|
58
|
+
else
|
|
59
|
+
streak = 0
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
streak
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse(line)
|
|
66
|
+
JSON.parse(line)
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "client"
|
|
5
5
|
require_relative "errors"
|
|
6
|
+
require_relative "flow_registry"
|
|
7
|
+
require_relative "replay/context"
|
|
8
|
+
require_relative "replay/fingerprint_matcher"
|
|
9
|
+
require_relative "replay/snapshot_diff"
|
|
6
10
|
require_relative "secret_resolvers"
|
|
7
11
|
require_relative "session"
|
|
8
12
|
|
|
@@ -12,11 +16,12 @@ module Browserctl
|
|
|
12
16
|
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
13
17
|
|
|
14
18
|
class WorkflowContext
|
|
15
|
-
attr_reader :client
|
|
19
|
+
attr_reader :client, :replay_context, :params
|
|
16
20
|
|
|
17
|
-
def initialize(params, client)
|
|
21
|
+
def initialize(params, client, replay_context: nil)
|
|
18
22
|
@params = params
|
|
19
23
|
@client = client
|
|
24
|
+
@replay_context = replay_context
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def store(key, value)
|
|
@@ -44,7 +49,7 @@ module Browserctl
|
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def page(name)
|
|
47
|
-
PageProxy.new(name.to_s, @client)
|
|
52
|
+
PageProxy.new(name.to_s, @client, replay_context: @replay_context)
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def open_page(page_name, url: nil)
|
|
@@ -68,7 +73,39 @@ module Browserctl
|
|
|
68
73
|
res
|
|
69
74
|
end
|
|
70
75
|
|
|
76
|
+
# Persists the daemon's current cookies + storage as a .bctl bundle.
|
|
77
|
+
# Optional flow binding lets `load_state` auto-rotate when the bundle
|
|
78
|
+
# is detected as needing authentication.
|
|
79
|
+
def save_state(name, flow: nil, origins: nil, encrypt: false)
|
|
80
|
+
passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
|
|
81
|
+
res = @client.state_save(name.to_s,
|
|
82
|
+
flow: flow&.to_s, origins: origins, passphrase: passphrase)
|
|
83
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
84
|
+
|
|
85
|
+
res
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
|
|
89
|
+
# applying (e.g. expired cookies in the payload), this rotates the bound
|
|
90
|
+
# flow and retries — no caller code change required.
|
|
91
|
+
#
|
|
92
|
+
# @param on_auth_required [Proc, nil] override the auto-rotate path. The
|
|
93
|
+
# block runs in the workflow context, in lieu of invoking the manifest's
|
|
94
|
+
# bound flow. Use this when the recovery procedure is bespoke.
|
|
95
|
+
def load_state(name, on_auth_required: nil)
|
|
96
|
+
res = @client.state_load(name.to_s)
|
|
97
|
+
return res unless auth_required_response?(res)
|
|
98
|
+
|
|
99
|
+
recover_auth_required_state(name.to_s, res, on_auth_required)
|
|
100
|
+
end
|
|
101
|
+
DEPRECATED_LOAD_SESSION_FALLBACK = <<~MSG
|
|
102
|
+
[browserctl] DEPRECATION: `load_session(name, fallback:, expired_if:)` is superseded by
|
|
103
|
+
`load_state(name)` with a flow-bound bundle (`save_state(name, flow: :name)`).
|
|
104
|
+
`load_session` will be removed in v0.12. See docs/concepts/state.md.
|
|
105
|
+
MSG
|
|
106
|
+
|
|
71
107
|
def load_session(session_name, fallback: nil, expired_if: nil)
|
|
108
|
+
warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
|
|
72
109
|
validate_expired_if!(expired_if)
|
|
73
110
|
fallback_name = fallback&.to_s
|
|
74
111
|
res = @client.session_load(session_name)
|
|
@@ -94,16 +131,41 @@ module Browserctl
|
|
|
94
131
|
$stdin.gets.chomp
|
|
95
132
|
end
|
|
96
133
|
|
|
97
|
-
def invoke(
|
|
98
|
-
name =
|
|
134
|
+
def invoke(target_name, page: nil, **override_params)
|
|
135
|
+
name = target_name.to_s
|
|
99
136
|
guard_circular!(name)
|
|
100
|
-
|
|
137
|
+
|
|
138
|
+
flow = lookup_flow_target(name)
|
|
139
|
+
if flow
|
|
140
|
+
track_invoke(name) { run_invoked_flow(flow, page_name: page, **override_params) }
|
|
141
|
+
else
|
|
142
|
+
track_invoke(name) { run_nested(target_name, **override_params) }
|
|
143
|
+
end
|
|
101
144
|
end
|
|
102
145
|
|
|
103
146
|
def assert(condition, msg = "assertion failed")
|
|
104
147
|
raise WorkflowError, msg unless condition
|
|
105
148
|
end
|
|
106
149
|
|
|
150
|
+
# Snapshots the named page and compares its digest against `expected_digest`.
|
|
151
|
+
# Under `workflow run --check` (a replay context is attached), a mismatch is
|
|
152
|
+
# recorded as a drift event with reason "post-snapshot mismatch" and the
|
|
153
|
+
# step still passes. Outside --check, mismatch raises WorkflowError so the
|
|
154
|
+
# workflow fails fast.
|
|
155
|
+
def assert_snapshot_stable(page_name, expected_digest:)
|
|
156
|
+
res = @client.snapshot(page_name.to_s, format: "elements")
|
|
157
|
+
snapshot = res[:snapshot]
|
|
158
|
+
actual = Replay::SnapshotDiff.digest(snapshot)
|
|
159
|
+
return if actual == expected_digest
|
|
160
|
+
|
|
161
|
+
msg = "post-snapshot mismatch on :#{page_name} — expected #{expected_digest}, got #{actual}"
|
|
162
|
+
raise WorkflowError, msg unless @replay_context
|
|
163
|
+
|
|
164
|
+
@replay_context.record(command: :assert_snapshot_stable, selector: page_name.to_s,
|
|
165
|
+
matched_ref: nil, score: nil, reason: "post-snapshot mismatch")
|
|
166
|
+
warn "[browserctl replay] #{msg}"
|
|
167
|
+
end
|
|
168
|
+
|
|
107
169
|
def compose(*)
|
|
108
170
|
raise WorkflowError,
|
|
109
171
|
"`compose` must be called at the workflow definition level, not inside a step block. " \
|
|
@@ -112,6 +174,45 @@ module Browserctl
|
|
|
112
174
|
|
|
113
175
|
private
|
|
114
176
|
|
|
177
|
+
def auth_required_response?(res)
|
|
178
|
+
(res[:code] || res["code"]) == "AUTH_REQUIRED"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def recover_auth_required_state(name, initial_res, on_auth_required)
|
|
182
|
+
if on_auth_required
|
|
183
|
+
on_auth_required.call
|
|
184
|
+
else
|
|
185
|
+
flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
|
|
186
|
+
unless flow_name && !flow_name.to_s.empty?
|
|
187
|
+
raise WorkflowError,
|
|
188
|
+
"state '#{name}' needs auth but bundle has no bound flow — " \
|
|
189
|
+
"save with `save_state('#{name}', flow: :NAME)` or pass on_auth_required:"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Match the daemon's `state load` preflight: it auth-checks the first
|
|
193
|
+
# open page (insertion order). Passing that same name to the flow
|
|
194
|
+
# gives stdlib flows a `page` proxy to drive (oauth_github reads
|
|
195
|
+
# `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
|
|
196
|
+
# page only when nothing is open — `state_save` would have errored
|
|
197
|
+
# earlier in that case, so this is a defence-in-depth nil.
|
|
198
|
+
invoke(flow_name, page: first_open_page)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
after_save = @client.state_save(name)
|
|
202
|
+
raise WorkflowError, after_save[:error] if after_save[:error]
|
|
203
|
+
|
|
204
|
+
retry_res = @client.state_load(name, skip_auth_check: true)
|
|
205
|
+
raise WorkflowError, retry_res[:error] if retry_res[:error]
|
|
206
|
+
|
|
207
|
+
retry_res.merge(rotated: true)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def first_open_page
|
|
211
|
+
res = @client.page_list
|
|
212
|
+
pages = res[:pages] || res["pages"] || []
|
|
213
|
+
pages.first
|
|
214
|
+
end
|
|
215
|
+
|
|
115
216
|
def validate_expired_if!(expired_if)
|
|
116
217
|
return unless expired_if
|
|
117
218
|
|
|
@@ -182,22 +283,43 @@ module Browserctl
|
|
|
182
283
|
def run_nested(workflow_name, **override_params)
|
|
183
284
|
Runner.new.run_workflow(workflow_name, **@params, **override_params)
|
|
184
285
|
end
|
|
286
|
+
|
|
287
|
+
def lookup_flow_target(name)
|
|
288
|
+
Browserctl.lookup_flow(name) || begin
|
|
289
|
+
FlowRegistry.resolve(name)
|
|
290
|
+
rescue ArgumentError
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def run_invoked_flow(flow, page_name:, **params)
|
|
296
|
+
proxy = page_name ? page(page_name) : nil
|
|
297
|
+
flow.run(page: proxy, client: @client, **params)
|
|
298
|
+
end
|
|
185
299
|
end
|
|
186
300
|
|
|
187
301
|
class PageProxy
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
302
|
+
attr_accessor :replay_context
|
|
303
|
+
|
|
304
|
+
def initialize(name, client, replay_context: nil, matcher: nil)
|
|
305
|
+
@name = name
|
|
306
|
+
@client = client
|
|
307
|
+
@replay_context = replay_context
|
|
308
|
+
@matcher = matcher || Replay::FingerprintMatcher.new
|
|
191
309
|
end
|
|
192
310
|
|
|
193
311
|
def navigate(url) = unwrap @client.navigate(@name, url)
|
|
194
312
|
|
|
195
313
|
def fill(selector = nil, value = nil, ref: nil)
|
|
196
|
-
|
|
314
|
+
with_selector_fallback(:fill, selector, ref) do |sel, r|
|
|
315
|
+
@client.fill(@name, sel, value, ref: r)
|
|
316
|
+
end
|
|
197
317
|
end
|
|
198
318
|
|
|
199
319
|
def click(selector = nil, ref: nil)
|
|
200
|
-
|
|
320
|
+
with_selector_fallback(:click, selector, ref) do |sel, r|
|
|
321
|
+
@client.click(@name, sel, ref: r)
|
|
322
|
+
end
|
|
201
323
|
end
|
|
202
324
|
|
|
203
325
|
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
@@ -219,15 +341,21 @@ module Browserctl
|
|
|
219
341
|
def press(key) = unwrap @client.press(@name, key)
|
|
220
342
|
|
|
221
343
|
def hover(selector = nil, ref: nil)
|
|
222
|
-
|
|
344
|
+
with_selector_fallback(:hover, selector, ref) do |sel, r|
|
|
345
|
+
@client.hover(@name, sel, ref: r)
|
|
346
|
+
end
|
|
223
347
|
end
|
|
224
348
|
|
|
225
349
|
def upload(selector = nil, path = nil, ref: nil)
|
|
226
|
-
|
|
350
|
+
with_selector_fallback(:upload, selector, ref) do |sel, r|
|
|
351
|
+
@client.upload(@name, sel, path, ref: r)
|
|
352
|
+
end
|
|
227
353
|
end
|
|
228
354
|
|
|
229
355
|
def select(selector = nil, value = nil, ref: nil)
|
|
230
|
-
|
|
356
|
+
with_selector_fallback(:select, selector, ref) do |sel, r|
|
|
357
|
+
@client.select(@name, sel, value, ref: r)
|
|
358
|
+
end
|
|
231
359
|
end
|
|
232
360
|
|
|
233
361
|
def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
|
|
@@ -235,6 +363,42 @@ module Browserctl
|
|
|
235
363
|
|
|
236
364
|
private
|
|
237
365
|
|
|
366
|
+
# Issues the wrapped command. If the daemon returns selector_not_found
|
|
367
|
+
# and a replay context has a fingerprint for this selector, takes a
|
|
368
|
+
# fresh snapshot, asks the matcher for a candidate, and retries by ref.
|
|
369
|
+
def with_selector_fallback(cmd, selector, ref)
|
|
370
|
+
res = yield(selector, ref)
|
|
371
|
+
return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
|
|
372
|
+
|
|
373
|
+
fp = @replay_context.fingerprint_for(selector)
|
|
374
|
+
return unwrap(res) unless fp
|
|
375
|
+
|
|
376
|
+
match = @matcher.best(fp, snapshot_entries)
|
|
377
|
+
unless match
|
|
378
|
+
@replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
|
|
379
|
+
return unwrap(res)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
log_rematch(cmd, selector, match)
|
|
383
|
+
@replay_context.record(command: cmd, selector: selector,
|
|
384
|
+
matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
|
|
385
|
+
unwrap(yield(nil, match.candidate[:ref]))
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def snapshot_entries
|
|
389
|
+
res = @client.snapshot(@name, format: "elements")
|
|
390
|
+
Array(res[:snapshot])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def selector_not_found?(res)
|
|
394
|
+
res.is_a?(Hash) && res[:code] == "selector_not_found"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def log_rematch(cmd, selector, match)
|
|
398
|
+
warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
|
|
399
|
+
"rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
|
|
400
|
+
end
|
|
401
|
+
|
|
238
402
|
def unwrap(res)
|
|
239
403
|
raise WorkflowError, res[:error] if res[:error]
|
|
240
404
|
|
|
@@ -273,8 +437,8 @@ module Browserctl
|
|
|
273
437
|
@steps.concat(source.steps)
|
|
274
438
|
end
|
|
275
439
|
|
|
276
|
-
def call(params, client)
|
|
277
|
-
ctx = WorkflowContext.new(resolve_params(params), client)
|
|
440
|
+
def call(params, client, replay_context: nil)
|
|
441
|
+
ctx = WorkflowContext.new(resolve_params(params), client, replay_context: replay_context)
|
|
278
442
|
execute_steps(ctx)
|
|
279
443
|
end
|
|
280
444
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -174,6 +174,7 @@ files:
|
|
|
174
174
|
- lib/browserctl/commands/daemon.rb
|
|
175
175
|
- lib/browserctl/commands/dialog.rb
|
|
176
176
|
- lib/browserctl/commands/fill.rb
|
|
177
|
+
- lib/browserctl/commands/flow.rb
|
|
177
178
|
- lib/browserctl/commands/init.rb
|
|
178
179
|
- lib/browserctl/commands/page.rb
|
|
179
180
|
- lib/browserctl/commands/record.rb
|
|
@@ -181,19 +182,32 @@ files:
|
|
|
181
182
|
- lib/browserctl/commands/screenshot.rb
|
|
182
183
|
- lib/browserctl/commands/session.rb
|
|
183
184
|
- lib/browserctl/commands/snapshot.rb
|
|
185
|
+
- lib/browserctl/commands/state.rb
|
|
184
186
|
- lib/browserctl/commands/storage.rb
|
|
185
187
|
- lib/browserctl/commands/workflow.rb
|
|
186
188
|
- lib/browserctl/constants.rb
|
|
187
189
|
- lib/browserctl/detectors.rb
|
|
190
|
+
- lib/browserctl/detectors/auth_required.rb
|
|
188
191
|
- lib/browserctl/driver.rb
|
|
189
192
|
- lib/browserctl/driver/base.rb
|
|
190
193
|
- lib/browserctl/driver/cdp.rb
|
|
191
194
|
- lib/browserctl/driver/cdp_page.rb
|
|
192
195
|
- lib/browserctl/errors.rb
|
|
193
196
|
- lib/browserctl/flow.rb
|
|
197
|
+
- lib/browserctl/flow_registry.rb
|
|
198
|
+
- lib/browserctl/flows/stdlib/basic_auth.rb
|
|
199
|
+
- lib/browserctl/flows/stdlib/cloudflare_solve.rb
|
|
200
|
+
- lib/browserctl/flows/stdlib/magic_link_email.rb
|
|
201
|
+
- lib/browserctl/flows/stdlib/oauth_github.rb
|
|
202
|
+
- lib/browserctl/flows/stdlib/oauth_google.rb
|
|
203
|
+
- lib/browserctl/flows/stdlib/totp_2fa.rb
|
|
194
204
|
- lib/browserctl/logger.rb
|
|
195
205
|
- lib/browserctl/policy.rb
|
|
196
206
|
- lib/browserctl/recording.rb
|
|
207
|
+
- lib/browserctl/replay/context.rb
|
|
208
|
+
- lib/browserctl/replay/fingerprint_matcher.rb
|
|
209
|
+
- lib/browserctl/replay/snapshot_diff.rb
|
|
210
|
+
- lib/browserctl/replay/telemetry.rb
|
|
197
211
|
- lib/browserctl/runner.rb
|
|
198
212
|
- lib/browserctl/secret_resolver_registry.rb
|
|
199
213
|
- lib/browserctl/secret_resolvers.rb
|
|
@@ -212,13 +226,28 @@ files:
|
|
|
212
226
|
- lib/browserctl/server/handlers/observation.rb
|
|
213
227
|
- lib/browserctl/server/handlers/page_lifecycle.rb
|
|
214
228
|
- lib/browserctl/server/handlers/session.rb
|
|
229
|
+
- lib/browserctl/server/handlers/state.rb
|
|
215
230
|
- lib/browserctl/server/handlers/storage.rb
|
|
216
231
|
- lib/browserctl/server/idle_watcher.rb
|
|
217
232
|
- lib/browserctl/server/page_session.rb
|
|
218
233
|
- lib/browserctl/server/snapshot_builder.rb
|
|
219
234
|
- lib/browserctl/session.rb
|
|
235
|
+
- lib/browserctl/snapshot/annotator.rb
|
|
236
|
+
- lib/browserctl/snapshot/extractor.rb
|
|
237
|
+
- lib/browserctl/snapshot/fingerprint.rb
|
|
238
|
+
- lib/browserctl/snapshot/ref.rb
|
|
239
|
+
- lib/browserctl/snapshot/serializer.rb
|
|
240
|
+
- lib/browserctl/state.rb
|
|
241
|
+
- lib/browserctl/state/bundle.rb
|
|
242
|
+
- lib/browserctl/state/transport.rb
|
|
243
|
+
- lib/browserctl/state/transports/file.rb
|
|
244
|
+
- lib/browserctl/state/transports/one_password.rb
|
|
245
|
+
- lib/browserctl/state/transports/s3.rb
|
|
220
246
|
- lib/browserctl/version.rb
|
|
221
247
|
- lib/browserctl/workflow.rb
|
|
248
|
+
- lib/browserctl/workflow/flow_wrapper.rb
|
|
249
|
+
- lib/browserctl/workflow/promoter.rb
|
|
250
|
+
- lib/browserctl/workflow/promotion_ledger.rb
|
|
222
251
|
homepage: https://github.com/patrick204nqh/browserctl
|
|
223
252
|
licenses:
|
|
224
253
|
- MIT
|