browserctl 0.12.0 → 0.13.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 +17 -0
- data/README.md +3 -3
- data/bin/browserctl +39 -32
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +0 -27
- data/lib/browserctl/commands/cli_output.rb +17 -3
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +56 -8
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +40 -11
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +33 -294
- data/lib/browserctl/server/command_dispatcher.rb +25 -16
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +20 -47
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +61 -237
- metadata +11 -8
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41aa6c550f8a9a6403781639ebbd381c653aedb03cfa95f732b74de3ee0a931d
|
|
4
|
+
data.tar.gz: 3feea758e797eca81e1ab269ca5d9011f82778510dab1ee45e31b80b9aa38048
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e100a2bce4a66b7ccd1cfaebfd5e5acb3b0f9c32aafec8427efe0f8d2b613ae954d9043a8d4da536284b7739508d4b318dab5e2abe16a83113938a23fb000796
|
|
7
|
+
data.tar.gz: b2171d7d6043c5c89084a21f50a5c53bbaa0ce1e891babeb30b4beb66ede7e41e8f87e7382980bb9f9229e453121cc99bd252aa4d54c48598d01bdbc7fbdadab
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,23 @@ 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.13.0](https://github.com/patrick204nqh/browserctl/compare/v0.12.0...v0.13.0) (2026-05-10)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### ⚠ BREAKING CHANGES
|
|
17
|
+
|
|
18
|
+
* `--output` is now a reserved global flag on every browserctl command. Existing invocations are unaffected (default mode `text` preserves prior byte-for-byte output), but commands that previously consumed `--output` as a positional or sub-flag would now collide. None ship in this gem.
|
|
19
|
+
* The CLI subcommands `snapshot`, `screenshot`, and `record` are removed with no aliases. Use `page snapshot`, `page screenshot`, and `recording start|stop|status` respectively.
|
|
20
|
+
* The `session` CLI commands and the `session_*` JSON-RPC wire commands are removed. Users on v0.12 sessions must regenerate state via `state save` before upgrading. The workflow DSL methods `save_session`, `load_session`, and `list_sessions` are also removed; use `save_state` / `load_state` instead.
|
|
21
|
+
* `store` and `fetch` wire commands are no longer part of the Fixed zone. They move to Extension and may change between minor releases with a changelog entry. The wire shape is unchanged in v0.13; the change is to the stability promise.
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* narrow Fixed zone — store/fetch and known overlaps ([#158](https://github.com/patrick204nqh/browserctl/issues/158)) ([067cb42](https://github.com/patrick204nqh/browserctl/commit/067cb423b5966784abc8be2395808b5e4fa92ac0))
|
|
26
|
+
* noun-verb CLI consistency ([#162](https://github.com/patrick204nqh/browserctl/issues/162)) ([8ceb729](https://github.com/patrick204nqh/browserctl/commit/8ceb72998008a8e6983cb4bad13ac5f286ad9ee8))
|
|
27
|
+
* remove session commands and code path ([#161](https://github.com/patrick204nqh/browserctl/issues/161)) ([12d58d9](https://github.com/patrick204nqh/browserctl/commit/12d58d9aa26b2609c98e4b34ce072a6dbb8baafe))
|
|
28
|
+
* unified --output {json,text,silent} on every CLI command ([#164](https://github.com/patrick204nqh/browserctl/issues/164)) ([f8947c6](https://github.com/patrick204nqh/browserctl/commit/f8947c655bc0b1f21bbf6067a059d6ab0b7d8b9e))
|
|
29
|
+
|
|
13
30
|
## [0.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
|
|
14
31
|
|
|
15
32
|
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Every browser automation tool restarts the browser when your script ends. That m
|
|
|
21
21
|
```bash
|
|
22
22
|
browserd & # start the daemon (headless)
|
|
23
23
|
browserctl page open main --url https://example.com/login
|
|
24
|
-
browserctl snapshot main # AI-friendly JSON snapshot with ref IDs
|
|
24
|
+
browserctl page snapshot main # AI-friendly JSON snapshot with ref IDs
|
|
25
25
|
browserctl fill main --ref e1 --value me@example.com # interact by ref, no selectors needed
|
|
26
26
|
browserctl click main --ref e2
|
|
27
27
|
browserctl daemon stop
|
|
@@ -42,7 +42,7 @@ browserd &
|
|
|
42
42
|
browserctl page open main --url https://moatazeldebsy.github.io/test-automation-practices/#/auth
|
|
43
43
|
|
|
44
44
|
# 4. Snapshot — returns JSON with a ref ID per interactable element
|
|
45
|
-
browserctl snapshot main
|
|
45
|
+
browserctl page snapshot main
|
|
46
46
|
# → [{"ref":"e1","tag":"input","attrs":{"data-test":"username-input"}}, {"ref":"e2",...}, {"ref":"e3","tag":"button","text":"Login",...}]
|
|
47
47
|
|
|
48
48
|
# 5. Interact using the ref IDs from the snapshot
|
|
@@ -52,7 +52,7 @@ browserctl click main --ref e3
|
|
|
52
52
|
|
|
53
53
|
# 6. Observe
|
|
54
54
|
browserctl url main
|
|
55
|
-
browserctl snapshot main --diff
|
|
55
|
+
browserctl page snapshot main --diff # only what changed
|
|
56
56
|
|
|
57
57
|
# Session persistence: save now, pick up later
|
|
58
58
|
browserctl session save my-session
|
data/bin/browserctl
CHANGED
|
@@ -14,17 +14,17 @@ require "json"
|
|
|
14
14
|
require "optimist"
|
|
15
15
|
require "browserctl"
|
|
16
16
|
require "browserctl/commands/cli_output"
|
|
17
|
+
require "browserctl/commands/output_format"
|
|
17
18
|
require "browserctl/commands/fill"
|
|
18
19
|
require "browserctl/commands/click"
|
|
19
20
|
require "browserctl/commands/snapshot"
|
|
20
21
|
require "browserctl/commands/screenshot"
|
|
21
|
-
require "browserctl/commands/
|
|
22
|
+
require "browserctl/commands/recording"
|
|
22
23
|
require "browserctl/commands/resume"
|
|
23
24
|
require "browserctl/commands/init"
|
|
24
25
|
require "browserctl/commands/page"
|
|
25
26
|
require "browserctl/commands/cookie"
|
|
26
27
|
require "browserctl/commands/storage"
|
|
27
|
-
require "browserctl/commands/session"
|
|
28
28
|
require "browserctl/commands/state"
|
|
29
29
|
require "browserctl/commands/daemon"
|
|
30
30
|
require "browserctl/commands/workflow"
|
|
@@ -42,9 +42,11 @@ def structured_stderr_payload(res, message)
|
|
|
42
42
|
[code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
def print_result(res)
|
|
45
|
+
def print_result(res, text_block = nil)
|
|
46
|
+
fmt = Browserctl::Commands::OutputFormat.current
|
|
47
|
+
|
|
46
48
|
unless res.is_a?(Hash) && (res[:error] || res["error"])
|
|
47
|
-
|
|
49
|
+
fmt.emit(res, text_block)
|
|
48
50
|
return
|
|
49
51
|
end
|
|
50
52
|
|
|
@@ -52,7 +54,7 @@ def print_result(res)
|
|
|
52
54
|
code, payload = structured_stderr_payload(res, message)
|
|
53
55
|
warn "Error: #{message}"
|
|
54
56
|
warn payload
|
|
55
|
-
puts res.to_json
|
|
57
|
+
puts res.to_json unless fmt.silent?
|
|
56
58
|
exit Browserctl::Error::ExitCodes.for(code)
|
|
57
59
|
end
|
|
58
60
|
|
|
@@ -64,10 +66,12 @@ def usage
|
|
|
64
66
|
init
|
|
65
67
|
|
|
66
68
|
Page:
|
|
67
|
-
page open
|
|
68
|
-
page close
|
|
69
|
+
page open <name> [--url URL]
|
|
70
|
+
page close <name>
|
|
69
71
|
page list
|
|
70
|
-
page focus
|
|
72
|
+
page focus <name>
|
|
73
|
+
page snapshot <name> [--format elements|html] [--diff]
|
|
74
|
+
page screenshot <name> [--out PATH] [--full]
|
|
71
75
|
|
|
72
76
|
Interaction (page is always first arg after verb):
|
|
73
77
|
navigate <page> <url>
|
|
@@ -75,8 +79,6 @@ def usage
|
|
|
75
79
|
<page> --ref <ref> --value <value>
|
|
76
80
|
click <page> <selector>
|
|
77
81
|
<page> --ref <ref>
|
|
78
|
-
snapshot <page> [--format elements|html] [--diff]
|
|
79
|
-
screenshot <page> [--out PATH] [--full]
|
|
80
82
|
evaluate <page> <expression>
|
|
81
83
|
url <page>
|
|
82
84
|
wait <page> <selector> [--timeout N]
|
|
@@ -105,14 +107,6 @@ def usage
|
|
|
105
107
|
storage import <page> <path>
|
|
106
108
|
storage delete <page> [--store local|session|all]
|
|
107
109
|
|
|
108
|
-
Session:
|
|
109
|
-
session save <name>
|
|
110
|
-
session load <name>
|
|
111
|
-
session list
|
|
112
|
-
session delete <name>
|
|
113
|
-
session export <name> <path>
|
|
114
|
-
session import <path>
|
|
115
|
-
|
|
116
110
|
Auth detection:
|
|
117
111
|
auth-check <page> [--cookies] [--state NAME] [--flow NAME]
|
|
118
112
|
# Exits 7 with code: AUTH_REQUIRED when the page needs login.
|
|
@@ -128,9 +122,9 @@ def usage
|
|
|
128
122
|
state import <source> [--name NAME]
|
|
129
123
|
|
|
130
124
|
Recording:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
recording start <name>
|
|
126
|
+
recording stop [--out PATH]
|
|
127
|
+
recording status
|
|
134
128
|
|
|
135
129
|
Workflow:
|
|
136
130
|
workflow run <name|file> [--check] [--params file] [--key value ...]
|
|
@@ -162,11 +156,23 @@ def usage
|
|
|
162
156
|
|
|
163
157
|
Global options:
|
|
164
158
|
--daemon <name> Connect to named or auto-indexed daemon (d1, d2, work, ...)
|
|
159
|
+
--output <fmt> Output format: json | text | silent (default: text;
|
|
160
|
+
override with BROWSERCTL_OUTPUT)
|
|
165
161
|
--version, -v
|
|
166
162
|
USAGE
|
|
167
163
|
exit 0
|
|
168
164
|
end
|
|
169
165
|
|
|
166
|
+
# Resolve --output {json,text,silent} (or BROWSERCTL_OUTPUT) once, globally,
|
|
167
|
+
# before per-command parsers run. Strips the flag from ARGV in place so
|
|
168
|
+
# downstream Optimist/positional parsers don't see it.
|
|
169
|
+
begin
|
|
170
|
+
Browserctl::Commands::OutputFormat.install!(ARGV)
|
|
171
|
+
rescue Browserctl::Commands::OutputFormat::InvalidFormat => e
|
|
172
|
+
warn "Error: #{e.message}"
|
|
173
|
+
exit 2
|
|
174
|
+
end
|
|
175
|
+
|
|
170
176
|
daemon_idx = ARGV.index("--daemon")
|
|
171
177
|
daemon_name = if daemon_idx
|
|
172
178
|
ARGV.delete_at(daemon_idx)
|
|
@@ -193,9 +199,9 @@ runner = Browserctl::Runner.new
|
|
|
193
199
|
|
|
194
200
|
begin
|
|
195
201
|
case cmd
|
|
196
|
-
when "workflow"
|
|
197
|
-
when "
|
|
198
|
-
when "init"
|
|
202
|
+
when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
|
|
203
|
+
when "recording" then Browserctl::Commands::Recording.run(args)
|
|
204
|
+
when "init" then Browserctl::Commands::Init.run(args)
|
|
199
205
|
when "ask" then Browserctl::Commands::Ask.run(args)
|
|
200
206
|
when "trace" then Browserctl::Commands::Trace.run(args)
|
|
201
207
|
when "migrate" then Browserctl::Commands::Migrate.run(args)
|
|
@@ -208,7 +214,6 @@ begin
|
|
|
208
214
|
when "page" then Browserctl::Commands::Page.run(client, args)
|
|
209
215
|
when "cookie" then Browserctl::Commands::Cookie.run(client, args)
|
|
210
216
|
when "storage" then Browserctl::Commands::Storage.run(client, args)
|
|
211
|
-
when "session" then Browserctl::Commands::Session.run(client, args)
|
|
212
217
|
when "state" then Browserctl::Commands::State.run(client, args)
|
|
213
218
|
when "daemon" then Browserctl::Commands::Daemon.run(client, args)
|
|
214
219
|
when "auth-check"
|
|
@@ -229,8 +234,6 @@ begin
|
|
|
229
234
|
when "navigate" then print_result(client.navigate(args[0], args[1]))
|
|
230
235
|
when "fill" then Browserctl::Commands::Fill.run(client, args)
|
|
231
236
|
when "click" then Browserctl::Commands::Click.run(client, args)
|
|
232
|
-
when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
|
|
233
|
-
when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
|
|
234
237
|
when "evaluate" then print_result(client.evaluate(args[0], args[1]))
|
|
235
238
|
when "url" then print_result(client.url(args[0]))
|
|
236
239
|
when "wait"
|
|
@@ -251,9 +254,12 @@ begin
|
|
|
251
254
|
warn "Error: #{res[:error]}"
|
|
252
255
|
exit 1
|
|
253
256
|
end
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
print_result(res.merge(paused: name, message: opts[:message])) do
|
|
258
|
+
lines = ["Page '#{name}' paused. Browser is live — interact freely."]
|
|
259
|
+
lines << "(#{opts[:message]})" if opts[:message]
|
|
260
|
+
lines << "When done: browserctl resume #{name}"
|
|
261
|
+
lines.join("\n")
|
|
262
|
+
end
|
|
257
263
|
when "resume" then Browserctl::Commands::Resume.run(client, args)
|
|
258
264
|
when "press"
|
|
259
265
|
name = args.shift or abort "usage: browserctl press <page> <key>"
|
|
@@ -282,8 +288,9 @@ begin
|
|
|
282
288
|
exit 1
|
|
283
289
|
end
|
|
284
290
|
url = res[:devtools_url]
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
print_result(res) do
|
|
292
|
+
"Opening DevTools for '#{name}':\n #{url}"
|
|
293
|
+
end
|
|
287
294
|
opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
|
|
288
295
|
system(opener, url)
|
|
289
296
|
else
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "secret_resolvers"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
# Shared base for {Flow} and {WorkflowDefinition}. Holds the duplicated
|
|
9
|
+
# DSL surface (`desc`, `param`, `step`) and the shared execution helpers
|
|
10
|
+
# for resolving params and running step blocks with retry + timeout.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses provide:
|
|
13
|
+
# - their own `call`/`run` entry point and context object;
|
|
14
|
+
# - {#callable_kind} so cross-type composition can be rejected at
|
|
15
|
+
# definition time (e.g. a flow trying to `compose` a workflow);
|
|
16
|
+
# - {#step_failure_message} for the typed error wording.
|
|
17
|
+
#
|
|
18
|
+
# The persistence DSL (`store`/`fetch`/`save_state`/`load_state`) is
|
|
19
|
+
# mixed into `WorkflowContext` via {ContextualPersistence} and is
|
|
20
|
+
# deliberately absent from `FlowContext` — flows return state, workflows
|
|
21
|
+
# share state.
|
|
22
|
+
class CallableDefinition
|
|
23
|
+
ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
24
|
+
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
attr_reader :name, :description, :param_defs, :steps
|
|
27
|
+
|
|
28
|
+
def initialize(name)
|
|
29
|
+
@name = name.to_s
|
|
30
|
+
@description = nil
|
|
31
|
+
@param_defs = {}
|
|
32
|
+
@steps = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def desc(text)
|
|
36
|
+
@description = text.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
40
|
+
secret = true if secret_ref
|
|
41
|
+
@param_defs[name] = ParamDef.new(
|
|
42
|
+
name: name,
|
|
43
|
+
required: required,
|
|
44
|
+
secret: secret,
|
|
45
|
+
default: default,
|
|
46
|
+
secret_ref: secret_ref
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def step(label, retry_count: 0, timeout: nil, &block)
|
|
51
|
+
raise ArgumentError, "#{callable_kind} step '#{label}' requires a block" unless block
|
|
52
|
+
|
|
53
|
+
@steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Subclasses override these to specialise the base behaviour.
|
|
57
|
+
|
|
58
|
+
# @return [Symbol] :flow or :workflow — used for cross-type composition checks.
|
|
59
|
+
def callable_kind
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_params(provided)
|
|
66
|
+
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
67
|
+
val = if defn.secret_ref
|
|
68
|
+
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
69
|
+
elsif provided.key?(name)
|
|
70
|
+
provided[name]
|
|
71
|
+
else
|
|
72
|
+
defn.default
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise missing_param_error(name) if defn.required && val.nil?
|
|
76
|
+
|
|
77
|
+
out[name] = val
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Override for typed error wording.
|
|
82
|
+
def missing_param_error(_name)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Runs a step block with retry + timeout. Yields the recoverable error
|
|
87
|
+
# to the caller (via the returned exception in `:error`) so subclasses
|
|
88
|
+
# can decide how to surface a failure (raise vs StepResult).
|
|
89
|
+
def execute_step_block(ctx, defn)
|
|
90
|
+
if defn.timeout
|
|
91
|
+
::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
|
|
92
|
+
else
|
|
93
|
+
ctx.instance_exec(&defn.block)
|
|
94
|
+
end
|
|
95
|
+
rescue ::Timeout::Error
|
|
96
|
+
raise step_timeout_error(defn)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def with_retries(defn)
|
|
100
|
+
last_error = nil
|
|
101
|
+
(defn.retry_count + 1).times do
|
|
102
|
+
return yield
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
last_error = e
|
|
105
|
+
end
|
|
106
|
+
[:error, last_error]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Subclasses override to raise their typed timeout error.
|
|
110
|
+
def step_timeout_error(_defn)
|
|
111
|
+
raise NotImplementedError
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -283,33 +283,6 @@ module Browserctl
|
|
|
283
283
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
284
284
|
def dialog_dismiss(name) = call("dialog_dismiss", name: name)
|
|
285
285
|
|
|
286
|
-
# Saves the current browser state (cookies, localStorage, open pages) to a named session.
|
|
287
|
-
# @param session_name [String] name for the saved session
|
|
288
|
-
# @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
|
|
289
|
-
def session_save(session_name, encrypt: false)
|
|
290
|
-
call("session_save", session_name: session_name, encrypt: encrypt)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Restores a previously saved session into the running daemon.
|
|
294
|
-
# @param session_name [String] name of the session to load
|
|
295
|
-
# @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
|
|
296
|
-
def session_load(session_name)
|
|
297
|
-
call("session_load", session_name: session_name)
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Lists all saved sessions.
|
|
301
|
-
# @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
|
|
302
|
-
def session_list
|
|
303
|
-
call("session_list")
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Permanently deletes a named session.
|
|
307
|
-
# @param session_name [String] name of the session to delete
|
|
308
|
-
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
309
|
-
def session_delete(session_name)
|
|
310
|
-
call("session_delete", session_name: session_name)
|
|
311
|
-
end
|
|
312
|
-
|
|
313
286
|
# Saves browser state (cookies + storage) into a single .bctl bundle.
|
|
314
287
|
# @return [Hash] `{ ok:, path:, origins:, cookies:, encrypted: }` or `{ error: }`
|
|
315
288
|
def state_save(name, origins: nil, flow: nil, flow_version: nil, passphrase: nil)
|
|
@@ -3,21 +3,35 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "../errors"
|
|
5
5
|
require_relative "../error/suggested_actions"
|
|
6
|
+
require_relative "output_format"
|
|
6
7
|
|
|
7
8
|
module Browserctl
|
|
8
9
|
module Commands
|
|
9
10
|
module CliOutput
|
|
10
11
|
AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
# Print the JSON-RPC daemon response, routed through the active
|
|
14
|
+
# `OutputFormat`. The historical default behaviour was `puts res.to_json`
|
|
15
|
+
# — keeping that as the `text` branch preserves byte-identical output
|
|
16
|
+
# for existing callers and golden files. `json` mode emits the same
|
|
17
|
+
# JSON explicitly. `silent` suppresses stdout entirely; errors still
|
|
18
|
+
# write the structured payload to stderr because errors are the
|
|
19
|
+
# result, not cosmetic output.
|
|
20
|
+
#
|
|
21
|
+
# `text_block` (optional) overrides the JSON dump in `text` mode for
|
|
22
|
+
# commands that have a distinct human-readable form (e.g. `init`).
|
|
23
|
+
def print_result(res, text_block = nil)
|
|
24
|
+
fmt = OutputFormat.current
|
|
25
|
+
|
|
13
26
|
if res.is_a?(Hash) && (res[:error] || res["error"])
|
|
14
27
|
message = res[:error] || res["error"]
|
|
15
28
|
warn "Error: #{message}"
|
|
16
29
|
warn structured_error_line(res, message)
|
|
17
|
-
puts res.to_json
|
|
30
|
+
puts res.to_json unless fmt.silent?
|
|
18
31
|
exit exit_code_for(res)
|
|
19
32
|
end
|
|
20
|
-
|
|
33
|
+
|
|
34
|
+
fmt.emit(res, text_block)
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
# Maps a daemon error response onto a process exit code. Defaults to 1;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optimist"
|
|
5
5
|
require_relative "cli_output"
|
|
6
|
+
require_relative "output_format"
|
|
6
7
|
|
|
7
8
|
module Browserctl
|
|
8
9
|
module Commands
|
|
@@ -18,7 +19,8 @@ module Browserctl
|
|
|
18
19
|
begin
|
|
19
20
|
print_result(client.ping)
|
|
20
21
|
rescue Browserctl::DaemonUnavailableError => e
|
|
21
|
-
|
|
22
|
+
payload = { ok: false, daemon: "offline", error: e.message }
|
|
23
|
+
OutputFormat.current.emit(payload)
|
|
22
24
|
exit 1
|
|
23
25
|
end
|
|
24
26
|
when "status" then run_status(client)
|
|
@@ -36,14 +38,16 @@ module Browserctl
|
|
|
36
38
|
url_res = client.url(name)
|
|
37
39
|
{ name: name, url: url_res[:url] || url_res[:error] }
|
|
38
40
|
end
|
|
39
|
-
|
|
41
|
+
payload = {
|
|
40
42
|
daemon: "online",
|
|
41
43
|
pid: ping[:pid],
|
|
42
44
|
protocol_version: ping[:protocol_version],
|
|
43
45
|
pages: page_info
|
|
44
|
-
|
|
46
|
+
}
|
|
47
|
+
OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
|
|
45
48
|
rescue Browserctl::DaemonUnavailableError => e
|
|
46
|
-
|
|
49
|
+
payload = { daemon: "offline", error: e.message }
|
|
50
|
+
OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
|
|
47
51
|
exit 1
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -57,7 +61,7 @@ module Browserctl
|
|
|
57
61
|
flags += ["--name", opts[:name]] if opts[:name]
|
|
58
62
|
pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
|
|
59
63
|
Process.detach(pid)
|
|
60
|
-
|
|
64
|
+
OutputFormat.current.emit({ ok: true, pid: pid }) { "browserd started (pid #{pid})" }
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def self.run_list
|
|
@@ -74,7 +78,7 @@ module Browserctl
|
|
|
74
78
|
rescue Browserctl::DaemonUnavailableError, RuntimeError
|
|
75
79
|
nil
|
|
76
80
|
end.compact
|
|
77
|
-
|
|
81
|
+
OutputFormat.current.emit({ daemons: rows })
|
|
78
82
|
end
|
|
79
83
|
end
|
|
80
84
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "cli_output"
|
|
5
|
+
require_relative "output_format"
|
|
5
6
|
require_relative "../flow_registry"
|
|
6
7
|
require_relative "../runner"
|
|
7
8
|
|
|
@@ -31,22 +32,22 @@ module Browserctl
|
|
|
31
32
|
page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
|
|
32
33
|
|
|
33
34
|
result = flow.run(page: page_proxy, client: client, **params)
|
|
34
|
-
|
|
35
|
+
OutputFormat.current.emit({ ok: true, flow: flow.name, result: serialisable(result) })
|
|
35
36
|
rescue Browserctl::FlowError => e
|
|
36
37
|
warn "Error: #{e.message}"
|
|
37
|
-
|
|
38
|
+
OutputFormat.current.emit({ ok: false, code: e.code, error: e.message }) unless OutputFormat.current.silent?
|
|
38
39
|
exit 1
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def self.run_list
|
|
42
43
|
entries = Browserctl::FlowRegistry.list
|
|
43
|
-
|
|
44
|
+
OutputFormat.current.emit({ flows: entries })
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def self.run_describe(args)
|
|
47
48
|
name = args.shift or abort "usage: browserctl flow describe <name>"
|
|
48
49
|
flow = resolve(name)
|
|
49
|
-
|
|
50
|
+
payload = {
|
|
50
51
|
name: flow.name,
|
|
51
52
|
desc: flow.description,
|
|
52
53
|
version: flow.version_string,
|
|
@@ -56,7 +57,8 @@ module Browserctl
|
|
|
56
57
|
steps: flow.steps.map(&:label),
|
|
57
58
|
postconditions: flow.postconditions.map(&:label),
|
|
58
59
|
produces_state: !flow.produces_state_block.nil?
|
|
59
|
-
|
|
60
|
+
}
|
|
61
|
+
OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
def self.resolve(name_or_path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "output_format"
|
|
4
5
|
|
|
5
6
|
module Browserctl
|
|
6
7
|
module Commands
|
|
@@ -14,15 +15,15 @@ module Browserctl
|
|
|
14
15
|
YAML
|
|
15
16
|
|
|
16
17
|
GITIGNORE_CONTENT = <<~GITIGNORE
|
|
17
|
-
#
|
|
18
|
-
|
|
18
|
+
# State bundles — contain credentials, never commit
|
|
19
|
+
state/
|
|
19
20
|
GITIGNORE
|
|
20
21
|
|
|
21
22
|
def self.run(_args)
|
|
22
23
|
FileUtils.mkdir_p(".browserctl/workflows")
|
|
23
24
|
FileUtils.touch(".browserctl/workflows/.keep")
|
|
24
25
|
|
|
25
|
-
FileUtils.mkdir_p(".browserctl/
|
|
26
|
+
FileUtils.mkdir_p(".browserctl/state")
|
|
26
27
|
|
|
27
28
|
gitignore_path = ".browserctl/.gitignore"
|
|
28
29
|
File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
|
|
@@ -30,10 +31,22 @@ module Browserctl
|
|
|
30
31
|
config_path = ".browserctl/config.yml"
|
|
31
32
|
File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
payload = {
|
|
35
|
+
ok: true,
|
|
36
|
+
paths: {
|
|
37
|
+
workflows: ".browserctl/workflows",
|
|
38
|
+
state: ".browserctl/state",
|
|
39
|
+
config: ".browserctl/config.yml"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
OutputFormat.current.emit(payload) do
|
|
43
|
+
<<~TEXT.chomp
|
|
44
|
+
Initialised browserctl project:
|
|
45
|
+
.browserctl/workflows/ (place workflow .rb files here)
|
|
46
|
+
.browserctl/state/ (state bundles — git-ignored)
|
|
47
|
+
.browserctl/config.yml (project settings)
|
|
48
|
+
TEXT
|
|
49
|
+
end
|
|
37
50
|
end
|
|
38
51
|
end
|
|
39
52
|
end
|