browserctl 0.7.0 → 0.8.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 +7 -0
- data/lib/browserctl/errors.rb +3 -0
- data/lib/browserctl/secret_resolver_registry.rb +39 -0
- data/lib/browserctl/secret_resolvers/base.rb +17 -0
- data/lib/browserctl/secret_resolvers/env.rb +13 -0
- data/lib/browserctl/secret_resolvers/macos_keychain.rb +29 -0
- data/lib/browserctl/secret_resolvers/one_password.rb +22 -0
- data/lib/browserctl/secret_resolvers.rb +14 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +24 -9
- data/lib/browserctl.rb +1 -0
- metadata +8 -4
- data/examples/smoke/params_file.rb +0 -36
- data/examples/smoke/store_fetch.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e63d2425e4bbd57beefebf31e3f616a3183823f467b08c89ba6a1e2bb32cca0b
|
|
4
|
+
data.tar.gz: 8f12b646d805237a46b2bba2c194fb91c21834dc78648db97a4f74b0cc672de6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 50521d3c938c009818d85c93b793c55f5ec2b6d7ae64ec0daae5bf337d793f795189f870d1451d5dca18b285c428a2dd48ec1c0b6af210e6e7c3b41a49c14989
|
|
7
|
+
data.tar.gz: fe973906dbfe35134aaa32c2e3080196febaa99dc4c8858683ceb9a9e41579f9a40b7ca7865af67ba946a6b2acc68dd7bafa31036daafeebe96e23edbd3f53c6
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,13 @@ All notable changes to this project will be documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **v0.8:** secret resolver plugin system + load_session fallback ([#54](https://github.com/patrick204nqh/browserctl/issues/54)) ([69737bf](https://github.com/patrick204nqh/browserctl/commit/69737bf10528ad691a31abf916953325637af597))
|
|
19
|
+
|
|
13
20
|
## [0.7.0](https://github.com/patrick204nqh/browserctl/compare/v0.6.0...v0.7.0) (2026-04-28)
|
|
14
21
|
|
|
15
22
|
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -22,4 +22,7 @@ module Browserctl
|
|
|
22
22
|
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
23
23
|
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
24
|
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
|
+
|
|
26
|
+
class WorkflowError < StandardError; end
|
|
27
|
+
class SecretResolverError < WorkflowError; end
|
|
25
28
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class SecretResolverRegistry
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@registry = {}
|
|
9
|
+
|
|
10
|
+
def self.register(resolver_class)
|
|
11
|
+
instance = resolver_class.new
|
|
12
|
+
@mutex.synchronize { @registry[resolver_class.scheme] = instance }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.resolve(secret_ref)
|
|
16
|
+
scheme, reference = secret_ref.split("://", 2)
|
|
17
|
+
resolver = @mutex.synchronize { @registry[scheme] }
|
|
18
|
+
raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
|
|
19
|
+
unless resolver.available?
|
|
20
|
+
raise SecretResolverError,
|
|
21
|
+
"'#{scheme}://' resolver is not available in this environment"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
resolver.resolve(reference)
|
|
25
|
+
rescue SecretResolverError
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise SecretResolverError, "secret resolution failed for #{secret_ref.inspect}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.registered?(scheme)
|
|
32
|
+
@mutex.synchronize { @registry.key?(scheme) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.reset!
|
|
36
|
+
@mutex.synchronize { @registry.clear }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module SecretResolvers
|
|
5
|
+
class Base
|
|
6
|
+
def self.scheme
|
|
7
|
+
raise NotImplementedError, "#{name}.scheme not implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def available? = true
|
|
11
|
+
|
|
12
|
+
def resolve(_reference)
|
|
13
|
+
raise NotImplementedError, "#{self.class.name}#resolve not implemented"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module SecretResolvers
|
|
5
|
+
class Env < Base
|
|
6
|
+
def self.scheme = "env"
|
|
7
|
+
|
|
8
|
+
def resolve(reference)
|
|
9
|
+
ENV.fetch(reference) { raise SecretResolverError, "env var '#{reference}' is not set" }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module SecretResolvers
|
|
7
|
+
class MacOSKeychain < Base
|
|
8
|
+
def self.scheme = "keychain"
|
|
9
|
+
|
|
10
|
+
def available?
|
|
11
|
+
RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(reference)
|
|
15
|
+
service, account = reference.split("/", 2)
|
|
16
|
+
if account.nil?
|
|
17
|
+
raise SecretResolverError,
|
|
18
|
+
"keychain reference must be 'service/account', got: #{reference.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result, status = Open3.capture2("security", "find-generic-password",
|
|
22
|
+
"-a", account, "-s", service, "-w")
|
|
23
|
+
raise SecretResolverError, "keychain item not found: #{reference}" unless status.success?
|
|
24
|
+
|
|
25
|
+
result.chomp
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module SecretResolvers
|
|
7
|
+
class OnePassword < Base
|
|
8
|
+
def self.scheme = "op"
|
|
9
|
+
|
|
10
|
+
def available?
|
|
11
|
+
system("which op > /dev/null 2>&1")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(reference)
|
|
15
|
+
result, status = Open3.capture2("op", "read", "op://#{reference}")
|
|
16
|
+
raise SecretResolverError, "1Password item not found: op://#{reference}" unless status.success?
|
|
17
|
+
|
|
18
|
+
result.chomp
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "secret_resolver_registry"
|
|
4
|
+
require_relative "secret_resolvers/base"
|
|
5
|
+
require_relative "secret_resolvers/env"
|
|
6
|
+
require_relative "secret_resolvers/macos_keychain"
|
|
7
|
+
require_relative "secret_resolvers/one_password"
|
|
8
|
+
|
|
9
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::Env)
|
|
10
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::MacOSKeychain)
|
|
11
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::OnePassword)
|
|
12
|
+
|
|
13
|
+
user_resolvers = File.expand_path("~/.browserctl/resolvers.rb")
|
|
14
|
+
load user_resolvers if File.exist?(user_resolvers)
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "client"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "secret_resolvers"
|
|
5
7
|
|
|
6
8
|
module Browserctl
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
|
|
9
|
+
ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
10
10
|
StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
|
|
11
11
|
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
12
12
|
|
|
@@ -67,11 +67,20 @@ module Browserctl
|
|
|
67
67
|
res
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def load_session(session_name)
|
|
70
|
+
def load_session(session_name, fallback: nil)
|
|
71
71
|
res = @client.session_load(session_name)
|
|
72
|
-
|
|
72
|
+
return res unless res[:error]
|
|
73
73
|
|
|
74
|
-
res
|
|
74
|
+
raise WorkflowError, res[:error] unless fallback
|
|
75
|
+
|
|
76
|
+
invoke(fallback.to_s)
|
|
77
|
+
res2 = @client.session_load(session_name)
|
|
78
|
+
if res2[:error]
|
|
79
|
+
raise WorkflowError,
|
|
80
|
+
"session '#{session_name}' still unavailable after running fallback '#{fallback}'"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
res2
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
def list_sessions
|
|
@@ -172,8 +181,10 @@ module Browserctl
|
|
|
172
181
|
@description = text
|
|
173
182
|
end
|
|
174
183
|
|
|
175
|
-
def param(name, required: false, secret: false, default: nil)
|
|
176
|
-
|
|
184
|
+
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
185
|
+
secret = true if secret_ref
|
|
186
|
+
@param_defs[name] =
|
|
187
|
+
ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
|
|
177
188
|
end
|
|
178
189
|
|
|
179
190
|
def step(label, retry_count: 0, timeout: nil, &block)
|
|
@@ -223,7 +234,11 @@ module Browserctl
|
|
|
223
234
|
|
|
224
235
|
def resolve_params(provided)
|
|
225
236
|
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
226
|
-
val =
|
|
237
|
+
val = if defn.secret_ref
|
|
238
|
+
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
239
|
+
else
|
|
240
|
+
provided[name] || defn.default
|
|
241
|
+
end
|
|
227
242
|
raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
|
|
228
243
|
|
|
229
244
|
out[name] = val
|
data/lib/browserctl.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "browserctl/version"
|
|
4
4
|
require_relative "browserctl/constants"
|
|
5
5
|
require_relative "browserctl/errors"
|
|
6
|
+
require_relative "browserctl/secret_resolvers"
|
|
6
7
|
require_relative "browserctl/workflow"
|
|
7
8
|
require_relative "browserctl/runner"
|
|
8
9
|
require_relative "browserctl/client"
|
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.8.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-04-
|
|
11
|
+
date: 2026-04-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -139,8 +139,6 @@ files:
|
|
|
139
139
|
- bin/browserd
|
|
140
140
|
- bin/setup
|
|
141
141
|
- examples/cloudflare_hitl.rb
|
|
142
|
-
- examples/smoke/params_file.rb
|
|
143
|
-
- examples/smoke/store_fetch.rb
|
|
144
142
|
- examples/test_automation_practices/advanced/ab_testing.rb
|
|
145
143
|
- examples/test_automation_practices/advanced/broken_images.rb
|
|
146
144
|
- examples/test_automation_practices/advanced/file_download.rb
|
|
@@ -191,6 +189,12 @@ files:
|
|
|
191
189
|
- lib/browserctl/policy.rb
|
|
192
190
|
- lib/browserctl/recording.rb
|
|
193
191
|
- lib/browserctl/runner.rb
|
|
192
|
+
- lib/browserctl/secret_resolver_registry.rb
|
|
193
|
+
- lib/browserctl/secret_resolvers.rb
|
|
194
|
+
- lib/browserctl/secret_resolvers/base.rb
|
|
195
|
+
- lib/browserctl/secret_resolvers/env.rb
|
|
196
|
+
- lib/browserctl/secret_resolvers/macos_keychain.rb
|
|
197
|
+
- lib/browserctl/secret_resolvers/one_password.rb
|
|
194
198
|
- lib/browserctl/server.rb
|
|
195
199
|
- lib/browserctl/server/command_dispatcher.rb
|
|
196
200
|
- lib/browserctl/server/handlers/cookies.rb
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Smoke test for --params file loading (Task 7.5).
|
|
5
|
-
#
|
|
6
|
-
# Run with:
|
|
7
|
-
# browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
|
|
8
|
-
#
|
|
9
|
-
# The workflow logs in using credentials from the params file and asserts
|
|
10
|
-
# the secure area is reached — proving the params were loaded and available.
|
|
11
|
-
|
|
12
|
-
Browserctl.workflow "smoke/params_file" do
|
|
13
|
-
desc "Smoke: load credentials from a --params file and use them in a workflow"
|
|
14
|
-
|
|
15
|
-
param :username, required: true
|
|
16
|
-
param :password, required: true, secret: true
|
|
17
|
-
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
18
|
-
|
|
19
|
-
step "open login page" do
|
|
20
|
-
open_page(:main, url: "#{base_url}/login")
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
step "fill credentials from params file" do
|
|
24
|
-
puts " [params] username = #{username.inspect}"
|
|
25
|
-
puts " [params] password = (#{password.length} chars, secret)"
|
|
26
|
-
page(:main).fill("input#username", username)
|
|
27
|
-
page(:main).fill("input#password", password)
|
|
28
|
-
page(:main).click("button[type=submit]")
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
step "assert login succeeded" do
|
|
32
|
-
page(:main).wait(".flash.success", timeout: 10)
|
|
33
|
-
assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
|
|
34
|
-
puts " [ok] reached secure area — params file loaded correctly"
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Smoke test for WorkflowContext#store / #fetch (Task 7.3).
|
|
5
|
-
#
|
|
6
|
-
# Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
|
|
7
|
-
# capture the text in step 1, assert it is still accessible in step 2 via fetch.
|
|
8
|
-
|
|
9
|
-
Browserctl.workflow "smoke/store_fetch" do
|
|
10
|
-
desc "Smoke: store a value in one step and retrieve it in a later step"
|
|
11
|
-
|
|
12
|
-
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
13
|
-
|
|
14
|
-
step "open dynamic loading page" do
|
|
15
|
-
open_page(:main, url: "#{base_url}/dynamic_loading/1")
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
step "click start and capture loaded text" do
|
|
19
|
-
page(:main).click("div#start button")
|
|
20
|
-
page(:main).wait("div#finish", timeout: 10)
|
|
21
|
-
text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
|
|
22
|
-
assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
|
|
23
|
-
store(:loaded_text, text)
|
|
24
|
-
puts " [store] loaded_text = #{text.inspect}"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
step "fetch value from previous step and assert" do
|
|
28
|
-
text = fetch(:loaded_text)
|
|
29
|
-
puts " [fetch] loaded_text = #{text.inspect}"
|
|
30
|
-
assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
step "confirm fetch raises for unknown key" do
|
|
34
|
-
fetch(:nonexistent_key)
|
|
35
|
-
assert false, "expected WorkflowError was not raised"
|
|
36
|
-
rescue Browserctl::WorkflowError => e
|
|
37
|
-
puts " [ok] WorkflowError raised as expected: #{e.message}"
|
|
38
|
-
end
|
|
39
|
-
end
|