browser-automation-skill 0.71.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 (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,448 @@
1
+ # scripts/lib/flow.sh — flow runner library (Phase 9 part 1-i).
2
+ #
3
+ # Three-fn API:
4
+ # flow_parse <flow-file> — parses YAML; sets FLOW_NAME, FLOW_SESSION,
5
+ # FLOW_VARS (assoc array); emits one
6
+ # {step_index, verb, args} JSON line per step
7
+ # on stdout.
8
+ # flow_apply_vars <step-json> — reads global FLOW_VARS; substitutes ${var}
9
+ # occurrences in step.args.* values; emits
10
+ # modified step JSON. ${refs.NAME} passes
11
+ # through literal (deferred to 9-1-ii).
12
+ # flow_dispatch <step-json> — translates step args → bash scripts/browser-
13
+ # <verb>.sh CLI invocation; runs it; wraps
14
+ # the verb's summary line into a step-event
15
+ # JSON line {step_index, verb, args, status,
16
+ # duration_ms, exit_code, summary} on stdout.
17
+ #
18
+ # YAML SUBSET (v1):
19
+ # - Top-level scalars: name, session (optional)
20
+ # - vars: block — flat key:value pairs, one per line, scalar values only
21
+ # - steps: list of single-key flow-style maps:
22
+ # - <verb>: { key: val, key: val }
23
+ # OR - <verb>: {}
24
+ # - Top-level keys other than {name, session, vars, steps} → warn + ignore
25
+ # - No nested maps, no list values in step bodies
26
+ # - No multi-line strings, no block scalars
27
+ # - ${var} substituted at parse-time via FLOW_VARS lookup
28
+ # - ${refs.NAME} left literal (resolution in 9-1-ii)
29
+ #
30
+ # Adapters / lib helpers are LEAVES — never source verb scripts. flow_dispatch
31
+ # shells out to browser-<verb>.sh as a subprocess; the verb script is a leaf
32
+ # from this library's POV.
33
+
34
+ [ -n "${BROWSER_SKILL_FLOW_LOADED:-}" ] && return 0
35
+ readonly BROWSER_SKILL_FLOW_LOADED=1
36
+
37
+ # Globals set by flow_parse:
38
+ # FLOW_NAME — string
39
+ # FLOW_SESSION — string (may be empty)
40
+ # FLOW_VARS — assoc array {key: value}
41
+ #
42
+ # Callers should `declare -gA FLOW_VARS=()` before calling flow_parse to
43
+ # reset state across multiple flows in the same shell.
44
+
45
+ flow_parse() {
46
+ local flow_file="$1"
47
+ [ -f "${flow_file}" ] || die "${EXIT_USAGE_ERROR}" "flow_parse: file not found: ${flow_file}"
48
+
49
+ # Reset globals (set in caller's shell — only useful when flow_parse is
50
+ # called WITHOUT command substitution; the `_meta` JSON line on stdout is
51
+ # the authoritative path for subshell-captured callers like browser-flow.sh).
52
+ FLOW_NAME=""
53
+ FLOW_SESSION=""
54
+ declare -gA FLOW_VARS=()
55
+
56
+ local in_vars=0 in_steps=0
57
+ local step_index=0
58
+ local line stripped
59
+ local steps_seen=0
60
+ # Build vars JSON object for the _meta line.
61
+ local vars_json='{}'
62
+
63
+ while IFS= read -r line || [ -n "${line}" ]; do
64
+ # Skip blank lines and full-line comments.
65
+ case "${line}" in
66
+ ''|'#'*) continue ;;
67
+ esac
68
+
69
+ # Top-level field detection (no leading whitespace).
70
+ case "${line}" in
71
+ 'name:'*)
72
+ FLOW_NAME="$(_flow_strip_value "${line#name:}")"
73
+ in_vars=0; in_steps=0
74
+ continue
75
+ ;;
76
+ 'session:'*)
77
+ FLOW_SESSION="$(_flow_strip_value "${line#session:}")"
78
+ in_vars=0; in_steps=0
79
+ continue
80
+ ;;
81
+ 'vars:'*)
82
+ in_vars=1; in_steps=0
83
+ continue
84
+ ;;
85
+ 'steps:'*)
86
+ in_vars=0; in_steps=1
87
+ steps_seen=1
88
+ # Emit _meta line BEFORE step lines so callers can read it first.
89
+ if [ -z "${FLOW_NAME}" ]; then
90
+ die "${EXIT_USAGE_ERROR}" "flow_parse: missing required field 'name'"
91
+ fi
92
+ jq -nc \
93
+ --arg name "${FLOW_NAME}" \
94
+ --arg session "${FLOW_SESSION}" \
95
+ --argjson vars "${vars_json}" \
96
+ '{_kind: "meta", name: $name, session: $session, vars: $vars}'
97
+ continue
98
+ ;;
99
+ esac
100
+
101
+ if [ "${in_vars}" = "1" ]; then
102
+ # Flat key: value indented under vars:.
103
+ stripped="$(_flow_strip_indent "${line}")"
104
+ case "${stripped}" in
105
+ *': '*)
106
+ local k v
107
+ k="${stripped%%: *}"
108
+ v="${stripped#*: }"
109
+ v="$(_flow_strip_value "${v}")"
110
+ FLOW_VARS["${k}"]="${v}"
111
+ # Append into vars_json.
112
+ vars_json="$(printf '%s' "${vars_json}" | jq -c --arg k "${k}" --arg v "${v}" '. + {($k): $v}')"
113
+ ;;
114
+ esac
115
+ continue
116
+ fi
117
+
118
+ if [ "${in_steps}" = "1" ]; then
119
+ stripped="$(_flow_strip_indent "${line}")"
120
+ case "${stripped}" in
121
+ '- '*)
122
+ # New step: `- <verb>: { ... }` OR `- <verb>: {}`
123
+ local body verb args_yaml
124
+ body="${stripped#- }"
125
+ verb="${body%%:*}"
126
+ args_yaml="${body#*:}"
127
+ args_yaml="$(_flow_strip_value "${args_yaml}")"
128
+ local args_json
129
+ args_json="$(_flow_inline_to_json "${args_yaml}")" \
130
+ || die "${EXIT_USAGE_ERROR}" "flow_parse: bad step body at index ${step_index}: ${args_yaml}"
131
+ jq -nc \
132
+ --arg kind "step" \
133
+ --argjson step_index "${step_index}" \
134
+ --arg verb "${verb}" \
135
+ --argjson args "${args_json}" \
136
+ '{_kind: $kind, step_index: $step_index, verb: $verb, args: $args}'
137
+ step_index=$((step_index + 1))
138
+ ;;
139
+ esac
140
+ continue
141
+ fi
142
+
143
+ # Unknown top-level key — warn but don't fail.
144
+ case "${line}" in
145
+ *': '*|*':')
146
+ printf 'flow_parse: warning: unknown top-level key in line: %s\n' "${line}" >&2
147
+ ;;
148
+ esac
149
+ done < "${flow_file}"
150
+
151
+ [ -n "${FLOW_NAME}" ] || die "${EXIT_USAGE_ERROR}" "flow_parse: missing required field 'name'"
152
+ [ "${steps_seen}" = "1" ] || die "${EXIT_USAGE_ERROR}" "flow_parse: missing required field 'steps'"
153
+ }
154
+
155
+ # _flow_strip_value <raw> — strip leading space + trailing whitespace + quotes.
156
+ _flow_strip_value() {
157
+ local v="$1"
158
+ # Trim leading whitespace.
159
+ v="${v#"${v%%[![:space:]]*}"}"
160
+ # Trim trailing whitespace.
161
+ v="${v%"${v##*[![:space:]]}"}"
162
+ # Strip surrounding double or single quotes.
163
+ case "${v}" in
164
+ '"'*'"') v="${v#\"}"; v="${v%\"}" ;;
165
+ "'"*"'") v="${v#\'}"; v="${v%\'}" ;;
166
+ esac
167
+ printf '%s' "${v}"
168
+ }
169
+
170
+ # _flow_strip_indent <line> — strip leading whitespace.
171
+ _flow_strip_indent() {
172
+ local v="$1"
173
+ v="${v#"${v%%[![:space:]]*}"}"
174
+ printf '%s' "${v}"
175
+ }
176
+
177
+ # _flow_inline_to_json <yaml-flow-style> → JSON object on stdout.
178
+ # Handles {} (empty), { k: v }, { k1: v1, k2: v2 }.
179
+ # Values are coerced to JSON: "true"/"false"/"null"/numbers as JSON; else
180
+ # wrapped as strings.
181
+ _flow_inline_to_json() {
182
+ local raw="$1"
183
+ raw="$(_flow_strip_value "${raw}")"
184
+ case "${raw}" in
185
+ '{}'|'') printf '{}'; return 0 ;;
186
+ '{'*'}') ;;
187
+ *) return 1 ;;
188
+ esac
189
+ # Strip outer braces.
190
+ raw="${raw#\{}"
191
+ raw="${raw%\}}"
192
+ raw="$(_flow_strip_value "${raw}")"
193
+ [ -z "${raw}" ] && { printf '{}'; return 0; }
194
+
195
+ # Use indexed variable names ($_k0, $_k1, ...) so hyphenated YAML keys
196
+ # like `dry-run` don't break jq's tokenizer (jq vars must match
197
+ # [A-Za-z_][A-Za-z0-9_]*). Field names go through jq's bracket-string
198
+ # accessor `.["dry-run"]` for the same reason.
199
+ local jq_args=() jq_filter='. = {}'
200
+ local IFS_orig="${IFS}"
201
+ IFS=','
202
+ local pair
203
+ local idx=0
204
+ for pair in ${raw}; do
205
+ IFS="${IFS_orig}"
206
+ pair="$(_flow_strip_value "${pair}")"
207
+ [ -z "${pair}" ] && continue
208
+ case "${pair}" in
209
+ *': '*) ;;
210
+ *':'*) ;;
211
+ *) return 1 ;;
212
+ esac
213
+ local k v key_jstr
214
+ k="${pair%%:*}"
215
+ v="${pair#*:}"
216
+ k="$(_flow_strip_value "${k}")"
217
+ v="$(_flow_strip_value "${v}")"
218
+ # Coerce values to JSON: numbers / true / false / null pass as JSON; else
219
+ # wrapped as strings. ${var} placeholders stay as strings.
220
+ if [[ "${v}" =~ ^-?(0|[1-9][0-9]*)(\.[0-9]+)?$ ]]; then
221
+ jq_args+=(--argjson "_k${idx}" "${v}")
222
+ elif [ "${v}" = "true" ] || [ "${v}" = "false" ] || [ "${v}" = "null" ]; then
223
+ jq_args+=(--argjson "_k${idx}" "${v}")
224
+ else
225
+ jq_args+=(--arg "_k${idx}" "${v}")
226
+ fi
227
+ # Encode key as a JSON string for safe interpolation into the filter.
228
+ key_jstr="$(printf '%s' "${k}" | jq -Rs .)"
229
+ jq_filter="${jq_filter} | .[${key_jstr}] = \$_k${idx}"
230
+ idx=$((idx + 1))
231
+ IFS=','
232
+ done
233
+ IFS="${IFS_orig}"
234
+ jq -nc "${jq_args[@]}" "${jq_filter}"
235
+ }
236
+
237
+ # flow_apply_vars <step-json> [refs-mode] — substitutes ${var} via FLOW_VARS
238
+ # and ${refs.NAME} via FLOW_REFS in step.args.* string values; emits modified
239
+ # step JSON. Both globals are assoc arrays the caller MUST declare:
240
+ #
241
+ # declare -gA FLOW_VARS=( [key]=val ... ) # populated by flow_parse + --var
242
+ # declare -gA FLOW_REFS=( [name]=ref ... ) # populated by browser-flow.sh
243
+ # # after each snapshot step's
244
+ # # event line (latest-wins).
245
+ #
246
+ # refs-mode (default "strict"):
247
+ # strict — resolve ${refs.X} via FLOW_REFS or die EXIT_USAGE_ERROR (per
248
+ # design doc §3 F3 — fail loud).
249
+ # skip — leave ${refs.X} as literal pass-through. Used by --dry-run, where
250
+ # FLOW_REFS isn't populated (no snapshot has actually run).
251
+ #
252
+ # Missing FLOW_VARS[<name>] always dies EXIT_USAGE_ERROR (vars are static).
253
+ flow_apply_vars() {
254
+ local step="$1"
255
+ local refs_mode="${2:-strict}"
256
+ local arg_keys
257
+ arg_keys="$(printf '%s' "${step}" | jq -r '.args | keys[]?' 2>/dev/null || printf '')"
258
+ local key val
259
+ while IFS= read -r key; do
260
+ [ -z "${key}" ] && continue
261
+ val="$(printf '%s' "${step}" | jq -r --arg k "${key}" '.args[$k]')"
262
+ [ "${val}" = "null" ] && continue
263
+ # Walk all ${...} occurrences; substitute via FLOW_VARS or FLOW_REFS.
264
+ local rest="${val}"
265
+ local out=""
266
+ while [[ "${rest}" == *'${'*'}'* ]]; do
267
+ out="${out}${rest%%\$\{*}"
268
+ rest="${rest#*\$\{}"
269
+ local placeholder="${rest%%\}*}"
270
+ rest="${rest#*\}}"
271
+ case "${placeholder}" in
272
+ refs.*)
273
+ local ref_name="${placeholder#refs.}"
274
+ if [ "${refs_mode}" = "skip" ]; then
275
+ # Leave literal pass-through (dry-run mode — no snapshot has run).
276
+ out="${out}\${${placeholder}}"
277
+ elif [ -z "${FLOW_REFS[${ref_name}]+x}" ]; then
278
+ die "${EXIT_USAGE_ERROR}" \
279
+ "flow_apply_vars: undefined ref '\${${placeholder}}' in step ${key} (no snapshot has surfaced \"${ref_name}\" — add a snapshot step first OR check the accessible name)"
280
+ else
281
+ out="${out}${FLOW_REFS[${ref_name}]}"
282
+ fi
283
+ ;;
284
+ *)
285
+ if [ -z "${FLOW_VARS[${placeholder}]+x}" ]; then
286
+ die "${EXIT_USAGE_ERROR}" "flow_apply_vars: undefined var '\${${placeholder}}' in step ${key}"
287
+ fi
288
+ out="${out}${FLOW_VARS[${placeholder}]}"
289
+ ;;
290
+ esac
291
+ done
292
+ out="${out}${rest}"
293
+ step="$(printf '%s' "${step}" | jq -c --arg k "${key}" --arg v "${out}" '.args[$k] = $v')"
294
+ done <<< "${arg_keys}"
295
+ printf '%s\n' "${step}"
296
+ }
297
+
298
+ # flow_dispatch <step-json> — translates step args to a verb-script call;
299
+ # runs `bash scripts/browser-<verb>.sh --key val ...`; wraps the verb's
300
+ # summary into a step-event JSON line on stdout.
301
+ #
302
+ # For unknown verbs (no scripts/browser-<verb>.sh), emits an error step-event
303
+ # with exit_code=41 (UNSUPPORTED_OP) and status=error. flow_dispatch itself
304
+ # always returns 0 — failure surfaces in the step-event payload, NOT the
305
+ # return code (so flow execution can continue partially).
306
+ flow_dispatch() {
307
+ local step="$1"
308
+ local step_index verb args_obj
309
+ step_index="$(printf '%s' "${step}" | jq -r '.step_index')"
310
+ verb="$(printf '%s' "${step}" | jq -r '.verb')"
311
+ args_obj="$(printf '%s' "${step}" | jq -c '.args')"
312
+
313
+ local script="${SCRIPTS_DIR:-${REPO_ROOT:-.}/scripts}/browser-${verb}.sh"
314
+ if [ ! -f "${script}" ]; then
315
+ jq -nc \
316
+ --argjson step_index "${step_index}" \
317
+ --arg verb "${verb}" \
318
+ --argjson args "${args_obj}" \
319
+ --arg status "error" \
320
+ --argjson exit_code 41 \
321
+ --arg error "no verb script at ${script}" \
322
+ '{step_index: $step_index, verb: $verb, args: $args, status: $status, exit_code: $exit_code, error: $error}'
323
+ return 0
324
+ fi
325
+
326
+ # Translate args object → CLI flags.
327
+ local cli_flags=()
328
+ local key val
329
+ while IFS= read -r key; do
330
+ [ -z "${key}" ] && continue
331
+ val="$(printf '%s' "${args_obj}" | jq -r --arg k "${key}" '.[$k]')"
332
+ if [ "${val}" = "true" ]; then
333
+ # Boolean true → bare flag.
334
+ cli_flags+=("--${key}")
335
+ elif [ "${val}" = "false" ]; then
336
+ # Boolean false → omit (flag absence is the false state).
337
+ :
338
+ else
339
+ cli_flags+=("--${key}" "${val}")
340
+ fi
341
+ done <<< "$(printf '%s' "${args_obj}" | jq -r 'keys[]?')"
342
+
343
+ local started_at_ms
344
+ started_at_ms="$(now_ms)"
345
+
346
+ local verb_out verb_exit
347
+ set +e
348
+ verb_out="$(bash "${script}" "${cli_flags[@]}" 2>&1)"
349
+ verb_exit=$?
350
+ set -e
351
+
352
+ local duration_ms=$(( $(now_ms) - started_at_ms ))
353
+
354
+ local last_line summary_json status
355
+ last_line="$(printf '%s\n' "${verb_out}" | tail -1)"
356
+ if printf '%s' "${last_line}" | jq -e . >/dev/null 2>&1; then
357
+ summary_json="${last_line}"
358
+ else
359
+ summary_json="null"
360
+ fi
361
+
362
+ if [ "${verb_exit}" = "0" ]; then
363
+ status="ok"
364
+ else
365
+ status="error"
366
+ fi
367
+
368
+ # Phase 9 part 1-ii: snapshot verbs emit an `event:snapshot` line carrying
369
+ # `refs[]` (text → ref accessibility map). Extract it; attach as step.refs
370
+ # so browser-flow.sh's main loop can update the global FLOW_REFS map.
371
+ # Other verbs: refs stays null. Defensive jq -e for "no event line found".
372
+ local refs_json="null"
373
+ if [ "${verb}" = "snapshot" ]; then
374
+ refs_json="$(
375
+ printf '%s\n' "${verb_out}" \
376
+ | jq -c -s 'map(select(.event == "snapshot")) | .[0].refs // null' 2>/dev/null \
377
+ || printf 'null'
378
+ )"
379
+ fi
380
+
381
+ jq -nc \
382
+ --argjson step_index "${step_index}" \
383
+ --arg verb "${verb}" \
384
+ --argjson args "${args_obj}" \
385
+ --arg status "${status}" \
386
+ --argjson duration_ms "${duration_ms}" \
387
+ --argjson exit_code "${verb_exit}" \
388
+ --argjson summary "${summary_json}" \
389
+ --argjson refs "${refs_json}" \
390
+ '{step_index: $step_index, verb: $verb, args: $args, status: $status, duration_ms: $duration_ms, exit_code: $exit_code, summary: $summary, refs: $refs}'
391
+
392
+ return 0
393
+ }
394
+
395
+ # flow_diff_steps <old-step-event-json> <new-step-event-json> — compares two
396
+ # step events (from steps.jsonl); emits one `event:replay_diff` JSON line on
397
+ # stdout. Returns 0 if both .status AND .summary match; 1 if either diverges.
398
+ #
399
+ # Used by browser-replay.sh's per-step diff loop. Per design doc §3 F5 + plan
400
+ # 2026-05-10-phase-09-part-1-iv-replay locked decisions D1 + D4.
401
+ flow_diff_steps() {
402
+ local old="$1"
403
+ local new="$2"
404
+
405
+ local step_index verb old_status new_status old_summary new_summary
406
+ step_index="$(printf '%s' "${new}" | jq -r '.step_index')"
407
+ verb="$(printf '%s' "${new}" | jq -r '.verb')"
408
+ old_status="$(printf '%s' "${old}" | jq -r '.status')"
409
+ new_status="$(printf '%s' "${new}" | jq -r '.status')"
410
+ # Strip timing-sensitive fields before output comparison — duration_ms
411
+ # always varies between runs and isn't a semantic difference. Per plan
412
+ # locked decision D4 (jq-equal on summary line; future iteration could
413
+ # add more granular per-field policies).
414
+ old_summary="$(printf '%s' "${old}" | jq -c '.summary | del(.duration_ms)')"
415
+ new_summary="$(printf '%s' "${new}" | jq -c '.summary | del(.duration_ms)')"
416
+
417
+ local status_match=true
418
+ [ "${old_status}" = "${new_status}" ] || status_match=false
419
+
420
+ local output_match=true
421
+ [ "${old_summary}" = "${new_summary}" ] || output_match=false
422
+
423
+ local output_diff_json='null'
424
+ if [ "${output_match}" = "false" ]; then
425
+ output_diff_json="$(jq -nc \
426
+ --argjson old "${old_summary}" \
427
+ --argjson new "${new_summary}" \
428
+ '{old: $old, new: $new}')"
429
+ fi
430
+
431
+ jq -nc \
432
+ --arg event "replay_diff" \
433
+ --argjson step_index "${step_index}" \
434
+ --arg verb "${verb}" \
435
+ --argjson status_match "${status_match}" \
436
+ --arg old_status "${old_status}" \
437
+ --arg new_status "${new_status}" \
438
+ --argjson output_match "${output_match}" \
439
+ --argjson output_diff "${output_diff_json}" \
440
+ '{event: $event, step_index: $step_index, verb: $verb,
441
+ status_match: $status_match, old_status: $old_status, new_status: $new_status,
442
+ output_match: $output_match, output_diff: $output_diff}'
443
+
444
+ if [ "${status_match}" = "true" ] && [ "${output_match}" = "true" ]; then
445
+ return 0
446
+ fi
447
+ return 1
448
+ }
@@ -0,0 +1,210 @@
1
+ # scripts/lib/flow_record.sh — flow recorder library (Phase 9 part 1-iii).
2
+ #
3
+ # Three-fn API:
4
+ # flow_record_detect_password <name>
5
+ # Returns 0 if <name> matches /password/i (case-insensitive substring).
6
+ # Per locked decision S1: any name containing "password" (any case) is
7
+ # a password field; recorded value is replaced with ${secrets.password}.
8
+ #
9
+ # flow_record_transform <out-name>
10
+ # Reads codegen JS on stdin; emits flow YAML on stdout. <out-name> is
11
+ # the value of the YAML's `name:` field. Detects password fields per
12
+ # flow_record_detect_password; writes ${secrets.password} placeholder
13
+ # in their place. Emits one stderr audit line per redaction.
14
+ #
15
+ # flow_record_emit_step <verb> <inline-args-yaml>
16
+ # Helper: prints ` - <verb>: { ... }` step line. Used by transformer.
17
+ #
18
+ # Codegen JS patterns supported (per locked decision F6-a):
19
+ # 1. await page.goto('URL')
20
+ # 2. await page.getByRole('textbox', { name: 'X' }).click()
21
+ # 3. await page.getByRole('textbox', { name: 'X' }).fill('V')
22
+ # 4. await page.getByRole('button', { name: 'X' }).click()
23
+ # 5. await page.locator('SELECTOR').click() # CSS selector only
24
+ # 6. await page.locator('SELECTOR').fill('V') # CSS selector only
25
+ #
26
+ # Out-of-scope codegen patterns (skipped with TODO comment):
27
+ # - xpath= selectors
28
+ # - waitForLoadState
29
+ # - storageState (codegen's session-save)
30
+ #
31
+ # Adapter authors: this lib is INTERNAL to the flow runner; verb scripts MUST
32
+ # NOT source it directly (composition through scripts/browser-flow.sh).
33
+
34
+ [ -n "${BROWSER_SKILL_FLOW_RECORD_LOADED:-}" ] && return 0
35
+ readonly BROWSER_SKILL_FLOW_RECORD_LOADED=1
36
+
37
+ # Globals set by flow_record_transform; readable by callers post-run.
38
+ FLOW_RECORD_PASSWORD_REDACTIONS=0
39
+ FLOW_RECORD_STEP_COUNT=0
40
+
41
+ # flow_record_detect_password <name> → exit 0 if name contains "password"
42
+ # (case-insensitive). Used by transformer to swap recorded values for the
43
+ # ${secrets.password} placeholder before persisting.
44
+ flow_record_detect_password() {
45
+ local name="$1"
46
+ local lower="${name,,}"
47
+ case "${lower}" in
48
+ *password*) return 0 ;;
49
+ *) return 1 ;;
50
+ esac
51
+ }
52
+
53
+ # flow_record_emit_step <verb> <inline-args-yaml> — prints one step line.
54
+ # Inline args are the exact YAML flow-style body (e.g. '{ url: /foo }' or '{}').
55
+ flow_record_emit_step() {
56
+ local verb="$1"
57
+ local args_yaml="$2"
58
+ printf ' - %s: %s\n' "${verb}" "${args_yaml}"
59
+ }
60
+
61
+ # _flow_record_emit_snapshot_if_needed
62
+ # Emits a snapshot step if the previous emitted step wasn't already a
63
+ # snapshot. Tracks via a closure-style bash global PREV_STEP_VERB.
64
+ _flow_record_emit_snapshot_if_needed() {
65
+ if [ "${PREV_STEP_VERB:-}" != "snapshot" ]; then
66
+ flow_record_emit_step "snapshot" "{}"
67
+ PREV_STEP_VERB="snapshot"
68
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
69
+ fi
70
+ }
71
+
72
+ # flow_record_transform <out-name> → reads codegen JS on stdin; emits flow
73
+ # YAML on stdout. Detects password fields per locked decision S1; writes
74
+ # audit line on stderr per redaction.
75
+ #
76
+ # Returns 0 on success; 2 on malformed JS input.
77
+ flow_record_transform() {
78
+ local out_name="${1:-recorded}"
79
+ FLOW_RECORD_PASSWORD_REDACTIONS=0
80
+ FLOW_RECORD_STEP_COUNT=0
81
+ PREV_STEP_VERB=""
82
+
83
+ # Header.
84
+ printf 'name: %s\n' "${out_name}"
85
+ printf 'steps:\n'
86
+
87
+ local line
88
+ while IFS= read -r line; do
89
+ case "${line}" in
90
+ *"page.goto("*)
91
+ # Pattern 1: await page.goto('URL')
92
+ local url
93
+ url="$(_flow_record_extract_arg "${line}" 'goto')"
94
+ [ -n "${url}" ] && {
95
+ flow_record_emit_step "open" "{ url: ${url} }"
96
+ PREV_STEP_VERB="open"
97
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
98
+ }
99
+ ;;
100
+ *"page.getByRole("*".click()"*|*"page.getByLabel("*".click()"*)
101
+ # Patterns 2 + 4: getByRole(... name: 'X' ...).click()
102
+ # OR getByLabel('X').click()
103
+ local accessible_name
104
+ accessible_name="$(_flow_record_extract_role_name "${line}")"
105
+ [ -z "${accessible_name}" ] && accessible_name="$(_flow_record_extract_label "${line}")"
106
+ [ -n "${accessible_name}" ] && {
107
+ _flow_record_emit_snapshot_if_needed
108
+ flow_record_emit_step "click" "{ ref: \${refs.${accessible_name}} }"
109
+ PREV_STEP_VERB="click"
110
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
111
+ }
112
+ ;;
113
+ *"page.getByRole("*".fill("*|*"page.getByLabel("*".fill("*)
114
+ # Pattern 3: getByRole(... name: 'X' ...).fill('V')
115
+ local accessible_name fill_value out_value
116
+ accessible_name="$(_flow_record_extract_role_name "${line}")"
117
+ [ -z "${accessible_name}" ] && accessible_name="$(_flow_record_extract_label "${line}")"
118
+ fill_value="$(_flow_record_extract_arg "${line}" 'fill')"
119
+ [ -n "${accessible_name}" ] && {
120
+ _flow_record_emit_snapshot_if_needed
121
+ if flow_record_detect_password "${accessible_name}"; then
122
+ out_value='${secrets.password}'
123
+ FLOW_RECORD_PASSWORD_REDACTIONS=$((FLOW_RECORD_PASSWORD_REDACTIONS + 1))
124
+ printf 'flow record: redacted password field "%s" → ${secrets.password} placeholder\n' \
125
+ "${accessible_name}" >&2
126
+ else
127
+ out_value="${fill_value}"
128
+ fi
129
+ flow_record_emit_step "fill" "{ ref: \${refs.${accessible_name}}, text: ${out_value} }"
130
+ PREV_STEP_VERB="fill"
131
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
132
+ }
133
+ ;;
134
+ *"page.locator("*"xpath="*)
135
+ # XPath selectors not supported (per locked decision F6-a).
136
+ printf ' # TODO(flow record): unsupported xpath selector — %s\n' \
137
+ "$(_flow_record_strip_leading_ws "${line}")"
138
+ ;;
139
+ *"page.locator("*".click()"*)
140
+ # Pattern 5: locator('CSS').click()
141
+ local selector
142
+ selector="$(_flow_record_extract_arg "${line}" 'locator')"
143
+ [ -n "${selector}" ] && {
144
+ flow_record_emit_step "click" "{ selector: ${selector} }"
145
+ PREV_STEP_VERB="click"
146
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
147
+ }
148
+ ;;
149
+ *"page.locator("*".fill("*)
150
+ # Pattern 6: locator('CSS').fill('V')
151
+ local selector fill_value
152
+ selector="$(_flow_record_extract_arg "${line}" 'locator')"
153
+ fill_value="$(_flow_record_extract_arg "${line}" 'fill')"
154
+ [ -n "${selector}" ] && [ -n "${fill_value}" ] && {
155
+ flow_record_emit_step "fill" "{ selector: ${selector}, text: ${fill_value} }"
156
+ PREV_STEP_VERB="fill"
157
+ FLOW_RECORD_STEP_COUNT=$((FLOW_RECORD_STEP_COUNT + 1))
158
+ }
159
+ ;;
160
+ esac
161
+ done
162
+ }
163
+
164
+ # Helper: strip leading whitespace.
165
+ _flow_record_strip_leading_ws() {
166
+ local v="$1"
167
+ v="${v#"${v%%[![:space:]]*}"}"
168
+ printf '%s' "${v}"
169
+ }
170
+
171
+ # Helper: extract single-quoted arg from `<fn>(...)` invocation in a line.
172
+ # E.g. _flow_record_extract_arg "page.goto('https://example.com')" "goto"
173
+ # → https://example.com
174
+ _flow_record_extract_arg() {
175
+ local line="$1"
176
+ local fn="$2"
177
+ # Find `<fn>('...')` portion; extract between the first pair of single quotes.
178
+ local rest="${line#*${fn}(}"
179
+ case "${rest}" in
180
+ "'"*)
181
+ rest="${rest#\'}"
182
+ printf '%s' "${rest%%\'*}"
183
+ ;;
184
+ *) printf '' ;;
185
+ esac
186
+ }
187
+
188
+ # Helper: extract `name: 'X'` value from getByRole(...) call.
189
+ _flow_record_extract_role_name() {
190
+ local line="$1"
191
+ case "${line}" in
192
+ *"name: '"*)
193
+ local rest="${line#*name: \'}"
194
+ printf '%s' "${rest%%\'*}"
195
+ ;;
196
+ *) printf '' ;;
197
+ esac
198
+ }
199
+
200
+ # Helper: extract `getByLabel('X')` value.
201
+ _flow_record_extract_label() {
202
+ local line="$1"
203
+ case "${line}" in
204
+ *"getByLabel('"*)
205
+ local rest="${line#*getByLabel(\'}"
206
+ printf '%s' "${rest%%\'*}"
207
+ ;;
208
+ *) printf '' ;;
209
+ esac
210
+ }