browserctl 0.11.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f20046bbdcf3ff57a52790137144c5f1197b6d3f8651f250bf6feb1cc9988f7
4
- data.tar.gz: 1d928edc69e4691cc720b9bb7f32ea4001d1fd5df636a4af7fc59341f7714174
3
+ metadata.gz: 41aa6c550f8a9a6403781639ebbd381c653aedb03cfa95f732b74de3ee0a931d
4
+ data.tar.gz: 3feea758e797eca81e1ab269ca5d9011f82778510dab1ee45e31b80b9aa38048
5
5
  SHA512:
6
- metadata.gz: 87be1521e16f71d8f77c699712fb03c7a339b2421102e6ad0d4bc9c1a0e860ff29882c81d0e5819a802b3364962922594bd9a08d80cdf5521e50ea09659dac98
7
- data.tar.gz: 29256e3d109bdfa651e8bea5fb82eef18fcb2b299c648db937015cebbfddd0d947e3e042b848adf32b61006f99d69150aaf38acf2a1356946c1584e2dc7a00b6
6
+ metadata.gz: e100a2bce4a66b7ccd1cfaebfd5e5acb3b0f9c32aafec8427efe0f8d2b613ae954d9043a8d4da536284b7739508d4b318dab5e2abe16a83113938a23fb000796
7
+ data.tar.gz: b2171d7d6043c5c89084a21f50a5c53bbaa0ce1e891babeb30b4beb66ede7e41e8f87e7382980bb9f9229e453121cc99bd252aa4d54c48598d01bdbc7fbdadab
data/CHANGELOG.md CHANGED
@@ -10,6 +10,51 @@ 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
+
30
+ ## [0.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
31
+
32
+
33
+ ### ⚠ BREAKING CHANGES
34
+
35
+ * Scripts that hard-coded `exit 7 = AUTH_REQUIRED` must update — AUTH_REQUIRED is now exit 3, and 7 is STATE_EXPIRED. Scripts treating any non-zero status as a generic failure are unaffected. Prior to this change the CLI exited 1 for everything except AUTH_REQUIRED (which already used 7); typed errors now surface their dedicated codes.
36
+
37
+ ### Features
38
+
39
+ * benchmark harness + bundle_codec target ([#154](https://github.com/patrick204nqh/browserctl/issues/154)) ([f4b815a](https://github.com/patrick204nqh/browserctl/commit/f4b815a617d4a7d22473e6028ce2a0f90467fa87))
40
+ * bundle format version ([#132](https://github.com/patrick204nqh/browserctl/issues/132)) ([b52d325](https://github.com/patrick204nqh/browserctl/commit/b52d32546d1b9ec3915fac014fecd8f929161ddb))
41
+ * crash reports ([#135](https://github.com/patrick204nqh/browserctl/issues/135)) ([b02adf4](https://github.com/patrick204nqh/browserctl/commit/b02adf4d41295dbe547d2965d838168d48efb8bf))
42
+ * error code enum ([#128](https://github.com/patrick204nqh/browserctl/issues/128)) ([feb0522](https://github.com/patrick204nqh/browserctl/commit/feb0522887935737fd96ad8a8b47d1407a5f585e))
43
+ * exit code map ([#140](https://github.com/patrick204nqh/browserctl/issues/140)) ([1a1d2b2](https://github.com/patrick204nqh/browserctl/commit/1a1d2b2f50693863cb508b5354e5546f340a2771))
44
+ * format version header convention ([#129](https://github.com/patrick204nqh/browserctl/issues/129)) ([405774d](https://github.com/patrick204nqh/browserctl/commit/405774dd6c99cc96f62f0c092010b30e7e710d41))
45
+ * migration helpers ([#143](https://github.com/patrick204nqh/browserctl/issues/143)) ([1c1762e](https://github.com/patrick204nqh/browserctl/commit/1c1762e755f83445c7cdd0df75fc5324a820fa19))
46
+ * recording format version ([#136](https://github.com/patrick204nqh/browserctl/issues/136)) ([4f1bc46](https://github.com/patrick204nqh/browserctl/commit/4f1bc467e653ecc1f508a85b3c329adf048b783d))
47
+ * structured error payload ([#137](https://github.com/patrick204nqh/browserctl/issues/137)) ([f028410](https://github.com/patrick204nqh/browserctl/commit/f02841069f68de749a76ba9a98a38b0c20836d76))
48
+ * structured JSONL logs ([#131](https://github.com/patrick204nqh/browserctl/issues/131)) ([c6daff4](https://github.com/patrick204nqh/browserctl/commit/c6daff488460606659c0641dd8cee6c23561be05))
49
+ * trace --redact ([#139](https://github.com/patrick204nqh/browserctl/issues/139)) ([19b4cec](https://github.com/patrick204nqh/browserctl/commit/19b4cec37daf95a06b0513780d991d0522069593))
50
+ * trace command ([#133](https://github.com/patrick204nqh/browserctl/issues/133)) ([156f304](https://github.com/patrick204nqh/browserctl/commit/156f304246d24fa6c3e06f8d941ac971629ea77c))
51
+ * workflow format version ([#138](https://github.com/patrick204nqh/browserctl/issues/138)) ([963776b](https://github.com/patrick204nqh/browserctl/commit/963776be785583fc3a3b0c04c97510e16c73ca9a))
52
+
53
+
54
+ ### Miscellaneous Chores
55
+
56
+ * target next release as 0.12.0 ([#156](https://github.com/patrick204nqh/browserctl/issues/156)) ([9797fcc](https://github.com/patrick204nqh/browserctl/commit/9797fcc0bae1a47bb72eec9cec5fda105e698406))
57
+
13
58
  ## [0.11.0](https://github.com/patrick204nqh/browserctl/compare/v0.10.0...v0.11.0) (2026-05-10)
14
59
 
15
60
 
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 # only what changed
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
@@ -198,6 +198,7 @@ The daemon shuts itself down after 30 minutes of inactivity.
198
198
  | [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
199
199
  | [Concepts](docs/concepts/) | Sessions, snapshots, [state](docs/concepts/state.md), [flows](docs/concepts/flows.md), human-in-the-loop |
200
200
  | [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
201
+ | [Debugging](docs/guides/debugging.md) | Read traces, redaction, crash reports, filing a good issue |
201
202
  | [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
202
203
  | [Command Reference](docs/reference/commands.md) | Every command and flag |
203
204
  | [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
data/bin/browserctl CHANGED
@@ -14,31 +14,48 @@ 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/record"
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"
31
31
  require "browserctl/commands/flow"
32
32
  require "browserctl/commands/dialog"
33
33
  require "browserctl/commands/ask"
34
+ require "browserctl/commands/trace"
35
+ require "browserctl/commands/migrate"
34
36
 
35
- def print_result(res)
36
- if res.is_a?(Hash) && (res[:error] || res["error"])
37
- warn "Error: #{res[:error] || res['error']}"
38
- puts res.to_json
39
- exit((res[:code] || res["code"]) == "AUTH_REQUIRED" ? 7 : 1)
37
+ def structured_stderr_payload(res, message)
38
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
39
+ context = res[:context] || res["context"] || {}
40
+ action = res[:suggested_action] || res["suggested_action"] ||
41
+ Browserctl::Error::SuggestedActions.for(code)
42
+ [code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
43
+ end
44
+
45
+ def print_result(res, text_block = nil)
46
+ fmt = Browserctl::Commands::OutputFormat.current
47
+
48
+ unless res.is_a?(Hash) && (res[:error] || res["error"])
49
+ fmt.emit(res, text_block)
50
+ return
40
51
  end
41
- puts res.to_json
52
+
53
+ message = res[:error] || res["error"]
54
+ code, payload = structured_stderr_payload(res, message)
55
+ warn "Error: #{message}"
56
+ warn payload
57
+ puts res.to_json unless fmt.silent?
58
+ exit Browserctl::Error::ExitCodes.for(code)
42
59
  end
43
60
 
44
61
  def usage
@@ -49,10 +66,12 @@ def usage
49
66
  init
50
67
 
51
68
  Page:
52
- page open <name> [--url URL]
53
- page close <name>
69
+ page open <name> [--url URL]
70
+ page close <name>
54
71
  page list
55
- page focus <name>
72
+ page focus <name>
73
+ page snapshot <name> [--format elements|html] [--diff]
74
+ page screenshot <name> [--out PATH] [--full]
56
75
 
57
76
  Interaction (page is always first arg after verb):
58
77
  navigate <page> <url>
@@ -60,8 +79,6 @@ def usage
60
79
  <page> --ref <ref> --value <value>
61
80
  click <page> <selector>
62
81
  <page> --ref <ref>
63
- snapshot <page> [--format elements|html] [--diff]
64
- screenshot <page> [--out PATH] [--full]
65
82
  evaluate <page> <expression>
66
83
  url <page>
67
84
  wait <page> <selector> [--timeout N]
@@ -90,14 +107,6 @@ def usage
90
107
  storage import <page> <path>
91
108
  storage delete <page> [--store local|session|all]
92
109
 
93
- Session:
94
- session save <name>
95
- session load <name>
96
- session list
97
- session delete <name>
98
- session export <name> <path>
99
- session import <path>
100
-
101
110
  Auth detection:
102
111
  auth-check <page> [--cookies] [--state NAME] [--flow NAME]
103
112
  # Exits 7 with code: AUTH_REQUIRED when the page needs login.
@@ -113,9 +122,9 @@ def usage
113
122
  state import <source> [--name NAME]
114
123
 
115
124
  Recording:
116
- record start <name>
117
- record stop [--out PATH]
118
- record status
125
+ recording start <name>
126
+ recording stop [--out PATH]
127
+ recording status
119
128
 
120
129
  Workflow:
121
130
  workflow run <name|file> [--check] [--params file] [--key value ...]
@@ -129,6 +138,15 @@ def usage
129
138
  flow list
130
139
  flow describe <name>
131
140
 
141
+ Trace:
142
+ trace [<session>] Pretty timeline of CLI + daemon log events.
143
+
144
+ Migrate:
145
+ migrate <path> [--to-version N] [--dry-run]
146
+ # Upgrades a persisted artifact (.bctl / .jsonl recording / workflow .rb)
147
+ # using registered migrations. Empty registry in v0.12 — first real
148
+ # migration ships post-1.0 when a format actually changes.
149
+
132
150
  Daemon:
133
151
  daemon start [--headed] [--name NAME]
134
152
  daemon stop
@@ -138,17 +156,40 @@ def usage
138
156
 
139
157
  Global options:
140
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)
141
161
  --version, -v
142
162
  USAGE
143
163
  exit 0
144
164
  end
145
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
+
146
176
  daemon_idx = ARGV.index("--daemon")
147
177
  daemon_name = if daemon_idx
148
178
  ARGV.delete_at(daemon_idx)
149
179
  ARGV.delete_at(daemon_idx)
150
180
  end
151
181
 
182
+ log_level_idx = ARGV.index("--log-level") || ARGV.index("-l")
183
+ log_level = if log_level_idx
184
+ ARGV.delete_at(log_level_idx)
185
+ ARGV.delete_at(log_level_idx)
186
+ end || ENV["BROWSERCTL_LOG_LEVEL"] || "info"
187
+
188
+ # CLI invocations write structured JSON Lines to ~/.browserctl/logs/cli.log
189
+ # alongside the existing stderr behaviour. Stderr stays human-readable so
190
+ # scripted callers see no change.
191
+ Browserctl.logger = Browserctl.build_logger(log_level, component: "cli")
192
+
152
193
  cmd = ARGV.shift
153
194
  args = ARGV.dup
154
195
 
@@ -156,99 +197,114 @@ usage if cmd.nil? || %w[-h --help help].include?(cmd)
156
197
 
157
198
  runner = Browserctl::Runner.new
158
199
 
159
- case cmd
160
- when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
161
- when "record" then Browserctl::Commands::Record.run(args)
162
- when "init" then Browserctl::Commands::Init.run(args)
163
- when "ask" then Browserctl::Commands::Ask.run(args)
200
+ begin
201
+ case cmd
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)
205
+ when "ask" then Browserctl::Commands::Ask.run(args)
206
+ when "trace" then Browserctl::Commands::Trace.run(args)
207
+ when "migrate" then Browserctl::Commands::Migrate.run(args)
164
208
 
165
- else
166
- client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
209
+ else
210
+ client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
167
211
 
168
- case cmd
169
- when "flow" then Browserctl::Commands::Flow.run(client, args)
170
- when "page" then Browserctl::Commands::Page.run(client, args)
171
- when "cookie" then Browserctl::Commands::Cookie.run(client, args)
172
- when "storage" then Browserctl::Commands::Storage.run(client, args)
173
- when "session" then Browserctl::Commands::Session.run(client, args)
174
- when "state" then Browserctl::Commands::State.run(client, args)
175
- when "daemon" then Browserctl::Commands::Daemon.run(client, args)
176
- when "auth-check"
177
- page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
178
- include_cookies = args.delete("--cookies") ? true : false
179
- state_idx = args.index("--state")
180
- state_arg = if state_idx
181
- (args.delete_at(state_idx)
182
- args.delete_at(state_idx))
183
- end
184
- flow_idx = args.index("--flow")
185
- flow_arg = if flow_idx
186
- (args.delete_at(flow_idx)
187
- args.delete_at(flow_idx))
188
- end
189
- print_result(client.auth_check(page_name, include_cookies: include_cookies,
190
- state: state_arg, suggested_flow: flow_arg))
191
- when "navigate" then print_result(client.navigate(args[0], args[1]))
192
- when "fill" then Browserctl::Commands::Fill.run(client, args)
193
- when "click" then Browserctl::Commands::Click.run(client, args)
194
- when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
195
- when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
196
- when "evaluate" then print_result(client.evaluate(args[0], args[1]))
197
- when "url" then print_result(client.url(args[0]))
198
- when "wait"
199
- opts = Optimist.options(args) do
200
- opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
212
+ case cmd
213
+ when "flow" then Browserctl::Commands::Flow.run(client, args)
214
+ when "page" then Browserctl::Commands::Page.run(client, args)
215
+ when "cookie" then Browserctl::Commands::Cookie.run(client, args)
216
+ when "storage" then Browserctl::Commands::Storage.run(client, args)
217
+ when "state" then Browserctl::Commands::State.run(client, args)
218
+ when "daemon" then Browserctl::Commands::Daemon.run(client, args)
219
+ when "auth-check"
220
+ page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
221
+ include_cookies = args.delete("--cookies") ? true : false
222
+ state_idx = args.index("--state")
223
+ state_arg = if state_idx
224
+ (args.delete_at(state_idx)
225
+ args.delete_at(state_idx))
226
+ end
227
+ flow_idx = args.index("--flow")
228
+ flow_arg = if flow_idx
229
+ (args.delete_at(flow_idx)
230
+ args.delete_at(flow_idx))
231
+ end
232
+ print_result(client.auth_check(page_name, include_cookies: include_cookies,
233
+ state: state_arg, suggested_flow: flow_arg))
234
+ when "navigate" then print_result(client.navigate(args[0], args[1]))
235
+ when "fill" then Browserctl::Commands::Fill.run(client, args)
236
+ when "click" then Browserctl::Commands::Click.run(client, args)
237
+ when "evaluate" then print_result(client.evaluate(args[0], args[1]))
238
+ when "url" then print_result(client.url(args[0]))
239
+ when "wait"
240
+ opts = Optimist.options(args) do
241
+ opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
242
+ end
243
+ name = args.shift
244
+ selector = args.shift
245
+ abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
246
+ print_result(client.wait(name, selector, timeout: opts[:timeout]))
247
+ when "pause"
248
+ opts = Optimist.options(args) do
249
+ opt :message, "Message shown to human", type: :string, short: "-m"
250
+ end
251
+ name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
252
+ res = client.pause(name, message: opts[:message])
253
+ if res[:error]
254
+ warn "Error: #{res[:error]}"
255
+ exit 1
256
+ end
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
263
+ when "resume" then Browserctl::Commands::Resume.run(client, args)
264
+ when "press"
265
+ name = args.shift or abort "usage: browserctl press <page> <key>"
266
+ key = args.shift or abort "usage: browserctl press <page> <key>"
267
+ print_result(client.press(name, key))
268
+ when "hover"
269
+ name = args.shift or abort "usage: browserctl hover <page> <selector>"
270
+ selector = args.shift or abort "usage: browserctl hover <page> <selector>"
271
+ print_result(client.hover(name, selector))
272
+ when "upload"
273
+ name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
274
+ selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
275
+ path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
276
+ print_result(client.upload(name, selector, path))
277
+ when "select"
278
+ name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
279
+ selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
280
+ value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
281
+ print_result(client.select(name, selector, value))
282
+ when "dialog" then Browserctl::Commands::Dialog.run(client, args)
283
+ when "devtools"
284
+ name = args.shift or abort "usage: browserctl devtools <page>"
285
+ res = client.devtools(name)
286
+ if res[:error]
287
+ warn "Error: #{res[:error]}"
288
+ exit 1
289
+ end
290
+ url = res[:devtools_url]
291
+ print_result(res) do
292
+ "Opening DevTools for '#{name}':\n #{url}"
293
+ end
294
+ opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
295
+ system(opener, url)
296
+ else
297
+ abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
201
298
  end
202
- name = args.shift
203
- selector = args.shift
204
- abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
205
- print_result(client.wait(name, selector, timeout: opts[:timeout]))
206
- when "pause"
207
- opts = Optimist.options(args) do
208
- opt :message, "Message shown to human", type: :string, short: "-m"
209
- end
210
- name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
211
- res = client.pause(name, message: opts[:message])
212
- if res[:error]
213
- warn "Error: #{res[:error]}"
214
- exit 1
215
- end
216
- puts "Page '#{name}' paused. Browser is live — interact freely."
217
- puts "(#{opts[:message]})" if opts[:message]
218
- puts "When done: browserctl resume #{name}"
219
- when "resume" then Browserctl::Commands::Resume.run(client, args)
220
- when "press"
221
- name = args.shift or abort "usage: browserctl press <page> <key>"
222
- key = args.shift or abort "usage: browserctl press <page> <key>"
223
- print_result(client.press(name, key))
224
- when "hover"
225
- name = args.shift or abort "usage: browserctl hover <page> <selector>"
226
- selector = args.shift or abort "usage: browserctl hover <page> <selector>"
227
- print_result(client.hover(name, selector))
228
- when "upload"
229
- name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
230
- selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
231
- path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
232
- print_result(client.upload(name, selector, path))
233
- when "select"
234
- name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
235
- selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
236
- value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
237
- print_result(client.select(name, selector, value))
238
- when "dialog" then Browserctl::Commands::Dialog.run(client, args)
239
- when "devtools"
240
- name = args.shift or abort "usage: browserctl devtools <page>"
241
- res = client.devtools(name)
242
- if res[:error]
243
- warn "Error: #{res[:error]}"
244
- exit 1
245
- end
246
- url = res[:devtools_url]
247
- puts "Opening DevTools for '#{name}':"
248
- puts " #{url}"
249
- opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
250
- system(opener, url)
251
- else
252
- abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
253
299
  end
300
+ rescue Browserctl::Error => e
301
+ payload = {
302
+ code: e.code,
303
+ message: e.message,
304
+ context: e.respond_to?(:context) ? (e.context || {}) : {},
305
+ suggested_action: Browserctl::Error::SuggestedActions.for(e.code)
306
+ }
307
+ warn "Error: #{e.message}"
308
+ warn JSON.generate(payload)
309
+ exit Browserctl::Error::ExitCodes.for(e.code)
254
310
  end
data/bin/browserd CHANGED
@@ -5,6 +5,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
 
6
6
  require "optimist"
7
7
  require "nokogiri"
8
+ require "browserctl/crash_report"
8
9
  require "browserctl/logger"
9
10
  require "browserctl/server"
10
11
  require "browserctl/version"
@@ -35,7 +36,9 @@ end
35
36
 
36
37
  log_path = Browserctl.log_path(assigned_name)
37
38
  warn "browserd starting — log: #{log_path}"
38
- Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
39
+ warn " if browserd crashes, attach the crash report from ~/.browserctl/logs/crash-*.json"
40
+ Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path, component: "daemon")
41
+ daemon_jsonl_log = File.join(Browserctl.log_dir, "daemon.log")
39
42
  begin
40
43
  Browserctl::Server.new(
41
44
  headless: !opts[:headed],
@@ -45,4 +48,8 @@ begin
45
48
  ).run
46
49
  rescue Browserctl::BrowserNotFound => e
47
50
  abort e.message
51
+ rescue Exception => e # rubocop:disable Lint/RescueException
52
+ crash_path = Browserctl::CrashReport.write(error: e, log_path: daemon_jsonl_log)
53
+ warn "browserd crashed — crash report: #{crash_path}" if crash_path
54
+ raise
48
55
  end
@@ -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