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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2662c63f1ab585b29689e8e09ac2c8f7c3214539c440c51da82efc954fc0493
4
- data.tar.gz: 3e8f6790cdb3a3a79382f68c3a1fb6235805e6acc96658270d06c4d91ee1098b
3
+ metadata.gz: e63d2425e4bbd57beefebf31e3f616a3183823f467b08c89ba6a1e2bb32cca0b
4
+ data.tar.gz: 8f12b646d805237a46b2bba2c194fb91c21834dc78648db97a4f74b0cc672de6
5
5
  SHA512:
6
- metadata.gz: d729193bb86f061227588d46981d7fc5aaf98ef45956a4f4c5e59ca30c95f02f2a06d0d2e9227e671e0f6fb8a3ffcdd2ec4347cc20bd30bb40b2bef54f8b25c4
7
- data.tar.gz: 48170f604f692e980da8956c41f74255e0f16c6a43a13f3ae090377550bc14967a632a8ac01ebe85c7cabde1ff4226638e92fd40329cde0bcb2539c0c4a5c69a
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
 
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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
- class WorkflowError < StandardError; end
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
- raise WorkflowError, res[:error] if res[:error]
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
- @param_defs[name] = ParamDef.new(name: name, required: required, secret: secret, default: default)
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 = provided[name] || defn.default
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.7.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-28 00:00:00.000000000 Z
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