browser-pilot 0.0.5 → 0.0.7

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.
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import "./chunk-ZIQA4JOT.mjs";
3
3
  import {
4
4
  connect
5
- } from "./chunk-NP56KSAN.mjs";
5
+ } from "./chunk-PCNEJAJ7.mjs";
6
6
  import "./chunk-BCOZUKWS.mjs";
7
7
  import {
8
8
  getBrowserWebSocketUrl
@@ -661,12 +661,1007 @@ COMMON ACTIONS
661
661
  snapshot {"action":"snapshot"}
662
662
  screenshot {"action":"screenshot"}
663
663
 
664
+ RECORDING (FOR HUMANS)
665
+ Want to create automations by demonstrating instead of coding?
666
+ Use 'bp record' to capture your browser interactions as replayable JSON:
667
+
668
+ bp record # Record from local Chrome
669
+ bp exec --file login.json # Replay the recording
670
+
671
+ Great for creating initial automation scripts that AI agents can refine.
672
+
664
673
  Run 'bp actions' for the complete action reference.
665
674
  `;
666
675
  async function quickstartCommand() {
667
676
  console.log(QUICKSTART);
668
677
  }
669
678
 
679
+ // src/recording/aggregator.ts
680
+ var INPUT_DEBOUNCE_MS = 300;
681
+ var NAVIGATION_DEBOUNCE_MS = 500;
682
+ function selectBestSelectors(candidates) {
683
+ const qualityOrder = {
684
+ "role-name": 0,
685
+ text: 1,
686
+ "aria-label": 2,
687
+ testid: 3,
688
+ "stable-attr": 4,
689
+ id: 5,
690
+ "name-attr": 6,
691
+ "css-path": 7
692
+ };
693
+ const sorted = [...candidates].sort((a, b) => {
694
+ const aOrder = qualityOrder[a.quality] ?? 8;
695
+ const bOrder = qualityOrder[b.quality] ?? 8;
696
+ return aOrder - bOrder;
697
+ });
698
+ const seen = /* @__PURE__ */ new Set();
699
+ const result = [];
700
+ for (const candidate of sorted) {
701
+ if (!seen.has(candidate.selector)) {
702
+ seen.add(candidate.selector);
703
+ result.push(candidate.selector);
704
+ }
705
+ }
706
+ return result;
707
+ }
708
+ function generateAnnotation(event) {
709
+ const { kind, element, url } = event;
710
+ const name = element?.accessibleName || element?.text || element?.ariaLabel;
711
+ const role = element?.computedRole || element?.role || element?.tag || "";
712
+ switch (kind) {
713
+ case "click":
714
+ if (name && role) {
715
+ return `Clicked '${name}' ${role}`;
716
+ } else if (name) {
717
+ return `Clicked '${name}'`;
718
+ } else if (role) {
719
+ return `Clicked ${role}`;
720
+ }
721
+ return "Clicked element";
722
+ case "dblclick":
723
+ if (name && role) {
724
+ return `Double-clicked '${name}' ${role}`;
725
+ }
726
+ return "Double-clicked element";
727
+ case "input":
728
+ if (name) {
729
+ return `Filled '${name}' with value`;
730
+ }
731
+ return "Filled input with value";
732
+ case "change":
733
+ if (element?.type === "checkbox" || element?.type === "radio") {
734
+ const action = event.checked ? "Checked" : "Unchecked";
735
+ if (name) {
736
+ return `${action} '${name}' ${element.type}`;
737
+ }
738
+ return `${action} ${element.type}`;
739
+ }
740
+ if (element?.tag === "select") {
741
+ if (name) {
742
+ return `Selected option in '${name}'`;
743
+ }
744
+ return "Selected option";
745
+ }
746
+ if (name) {
747
+ return `Changed '${name}'`;
748
+ }
749
+ return "Changed element";
750
+ case "submit":
751
+ if (name) {
752
+ return `Submitted '${name}' form`;
753
+ }
754
+ return "Submitted form";
755
+ case "keydown":
756
+ if (event.key === "Enter") {
757
+ return "Pressed Enter";
758
+ }
759
+ return `Pressed ${event.key}`;
760
+ case "navigation":
761
+ return `Navigated to ${url}`;
762
+ default:
763
+ if (name) {
764
+ return `${kind} on '${name}'`;
765
+ }
766
+ return `${kind} on element`;
767
+ }
768
+ }
769
+ function debounceInputEvents(events) {
770
+ const result = [];
771
+ for (let i = 0; i < events.length; i++) {
772
+ const event = events[i];
773
+ if (event.kind !== "input") {
774
+ result.push(event);
775
+ continue;
776
+ }
777
+ const primarySelector = event.selectors[0]?.selector;
778
+ if (!primarySelector) {
779
+ result.push(event);
780
+ continue;
781
+ }
782
+ let finalEvent = event;
783
+ let j = i + 1;
784
+ while (j < events.length) {
785
+ const nextEvent = events[j];
786
+ if (nextEvent.timestamp - finalEvent.timestamp > INPUT_DEBOUNCE_MS) {
787
+ break;
788
+ }
789
+ if (nextEvent.kind !== "input") {
790
+ break;
791
+ }
792
+ const nextPrimarySelector = nextEvent.selectors[0]?.selector;
793
+ if (nextPrimarySelector !== primarySelector) {
794
+ break;
795
+ }
796
+ finalEvent = nextEvent;
797
+ j++;
798
+ }
799
+ i = j - 1;
800
+ result.push(finalEvent);
801
+ }
802
+ return result;
803
+ }
804
+ function debounceNavigationEvents(events) {
805
+ const result = [];
806
+ for (let i = 0; i < events.length; i++) {
807
+ const event = events[i];
808
+ if (event.kind !== "navigation") {
809
+ result.push(event);
810
+ continue;
811
+ }
812
+ let finalEvent = event;
813
+ let j = i + 1;
814
+ while (j < events.length) {
815
+ const nextEvent = events[j];
816
+ if (nextEvent.timestamp - finalEvent.timestamp > NAVIGATION_DEBOUNCE_MS) {
817
+ break;
818
+ }
819
+ if (nextEvent.kind !== "navigation") {
820
+ break;
821
+ }
822
+ finalEvent = nextEvent;
823
+ j++;
824
+ }
825
+ i = j - 1;
826
+ result.push(finalEvent);
827
+ }
828
+ return result;
829
+ }
830
+ function insertNavigationSteps(events, startUrl) {
831
+ const result = [];
832
+ let lastUrl = startUrl || null;
833
+ for (const event of events) {
834
+ if (lastUrl !== null && event.url !== lastUrl) {
835
+ result.push({
836
+ kind: "navigation",
837
+ timestamp: event.timestamp,
838
+ url: event.url,
839
+ selectors: []
840
+ });
841
+ }
842
+ result.push(event);
843
+ lastUrl = event.url;
844
+ }
845
+ return result;
846
+ }
847
+ function buildElementMeta(event) {
848
+ const el = event.element;
849
+ if (!el) return void 0;
850
+ return {
851
+ role: el.computedRole || el.role,
852
+ name: el.accessibleName || el.text || el.ariaLabel,
853
+ tag: el.tag
854
+ };
855
+ }
856
+ function eventToStep(event) {
857
+ const selectors = selectBestSelectors(event.selectors);
858
+ const elementMeta = buildElementMeta(event);
859
+ const annotation = generateAnnotation(event);
860
+ switch (event.kind) {
861
+ case "click":
862
+ case "dblclick":
863
+ if (selectors.length === 0) return null;
864
+ return {
865
+ action: "click",
866
+ selector: selectors.length === 1 ? selectors[0] : selectors,
867
+ element: elementMeta,
868
+ annotation
869
+ };
870
+ case "input":
871
+ if (selectors.length === 0) return null;
872
+ return {
873
+ action: "fill",
874
+ selector: selectors.length === 1 ? selectors[0] : selectors,
875
+ value: event.value ?? "",
876
+ element: elementMeta,
877
+ annotation
878
+ };
879
+ case "change": {
880
+ if (selectors.length === 0) return null;
881
+ const element = event.element;
882
+ const tag = element?.tag;
883
+ const type = element?.type?.toLowerCase();
884
+ if (tag === "select") {
885
+ return {
886
+ action: "select",
887
+ selector: selectors.length === 1 ? selectors[0] : selectors,
888
+ value: event.value ?? "",
889
+ element: elementMeta,
890
+ annotation
891
+ };
892
+ }
893
+ if (type === "checkbox" || type === "radio") {
894
+ return {
895
+ action: event.checked ? "check" : "uncheck",
896
+ selector: selectors.length === 1 ? selectors[0] : selectors,
897
+ element: elementMeta,
898
+ annotation
899
+ };
900
+ }
901
+ return {
902
+ action: "fill",
903
+ selector: selectors.length === 1 ? selectors[0] : selectors,
904
+ value: event.value ?? "",
905
+ element: elementMeta,
906
+ annotation
907
+ };
908
+ }
909
+ case "keydown":
910
+ if (event.key === "Enter") {
911
+ if (selectors.length === 0) return null;
912
+ return {
913
+ action: "submit",
914
+ selector: selectors.length === 1 ? selectors[0] : selectors,
915
+ method: "enter",
916
+ element: elementMeta,
917
+ annotation
918
+ };
919
+ }
920
+ return null;
921
+ case "submit":
922
+ if (selectors.length === 0) return null;
923
+ return {
924
+ action: "submit",
925
+ selector: selectors.length === 1 ? selectors[0] : selectors,
926
+ element: elementMeta,
927
+ annotation
928
+ };
929
+ case "navigation":
930
+ return {
931
+ action: "goto",
932
+ url: event.url,
933
+ annotation
934
+ };
935
+ default:
936
+ return null;
937
+ }
938
+ }
939
+ function deduplicateSteps(steps) {
940
+ const result = [];
941
+ for (let i = 0; i < steps.length; i++) {
942
+ const step = steps[i];
943
+ const prevStep = result[result.length - 1];
944
+ if (step.action === "submit" && prevStep?.action === "submit" && JSON.stringify(step.selector) === JSON.stringify(prevStep.selector)) {
945
+ continue;
946
+ }
947
+ result.push(step);
948
+ }
949
+ return result;
950
+ }
951
+ function aggregateEvents(events, startUrl) {
952
+ if (events.length === 0) return [];
953
+ let processed = insertNavigationSteps(events, startUrl);
954
+ processed = debounceNavigationEvents(processed);
955
+ processed = debounceInputEvents(processed);
956
+ const steps = [];
957
+ for (const event of processed) {
958
+ const step = eventToStep(event);
959
+ if (step) {
960
+ steps.push(step);
961
+ }
962
+ }
963
+ return deduplicateSteps(steps);
964
+ }
965
+
966
+ // src/recording/script.ts
967
+ var RECORDER_BINDING_NAME = "__recorder";
968
+ var RECORDER_SCRIPT = `(function() {
969
+ // Guard against multiple installations
970
+ if (window.__recorderInstalled) return;
971
+ window.__recorderInstalled = true;
972
+
973
+ const BINDING_NAME = '__recorder';
974
+
975
+ // Safe JSON stringify
976
+ function safeJson(obj) {
977
+ try {
978
+ return JSON.stringify(obj);
979
+ } catch (e) {
980
+ return JSON.stringify({ error: 'unserializable' });
981
+ }
982
+ }
983
+
984
+ // Send event to CDP client via binding
985
+ function sendEvent(payload) {
986
+ try {
987
+ if (typeof window[BINDING_NAME] === 'function') {
988
+ window[BINDING_NAME](safeJson(payload));
989
+ }
990
+ } catch (e) {
991
+ // Binding not ready, ignore
992
+ }
993
+ }
994
+
995
+ // CSS escape for identifiers
996
+ function cssEscape(str) {
997
+ return String(str).replace(/([\\[\\]#.:>+~=|^$*!"'(){}])/g, '\\\\$1');
998
+ }
999
+
1000
+ // Check if selector is unique in document
1001
+ function isUnique(selector, root) {
1002
+ try {
1003
+ return (root || document).querySelectorAll(selector).length === 1;
1004
+ } catch (e) {
1005
+ return false;
1006
+ }
1007
+ }
1008
+
1009
+ // Get stable attribute selector (data-testid, aria-label, name, etc.)
1010
+ function getStableAttrSelector(el) {
1011
+ if (!el || el.nodeType !== 1) return null;
1012
+ const attrs = ['data-testid', 'data-test', 'data-qa', 'aria-label', 'name'];
1013
+ for (const attr of attrs) {
1014
+ const val = el.getAttribute(attr);
1015
+ if (val && val.length <= 200) {
1016
+ const escaped = val.replace(/"/g, '\\\\"');
1017
+ return '[' + attr + '="' + escaped + '"]';
1018
+ }
1019
+ }
1020
+ return null;
1021
+ }
1022
+
1023
+ // Get ID selector
1024
+ function getIdSelector(el) {
1025
+ if (!el || !el.id || el.id.length > 100) return null;
1026
+ // Skip dynamic-looking IDs
1027
+ if (/^[0-9]|^:/.test(el.id)) return null;
1028
+ return '#' + cssEscape(el.id);
1029
+ }
1030
+
1031
+ // Build CSS path for element
1032
+ function buildCssPath(el) {
1033
+ if (!el || el.nodeType !== 1) return null;
1034
+ const parts = [];
1035
+ let cur = el;
1036
+
1037
+ for (let depth = 0; cur && cur !== document.body && depth < 8; depth++) {
1038
+ let part = cur.tagName.toLowerCase();
1039
+
1040
+ // If ID exists and looks stable, use it and stop
1041
+ if (cur.id && !/^[0-9]|^:/.test(cur.id) && cur.id.length <= 50) {
1042
+ part = '#' + cssEscape(cur.id);
1043
+ parts.unshift(part);
1044
+ break;
1045
+ }
1046
+
1047
+ // Add stable classes (skip dynamic ones)
1048
+ const classes = Array.from(cur.classList || [])
1049
+ .filter(c => c.length < 40 && !/^css-|^_|^[0-9]/.test(c))
1050
+ .slice(0, 2);
1051
+ if (classes.length) {
1052
+ part += '.' + classes.map(cssEscape).join('.');
1053
+ }
1054
+
1055
+ // Add position if siblings have same tag
1056
+ const parent = cur.parentElement;
1057
+ if (parent) {
1058
+ const sameTag = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
1059
+ if (sameTag.length > 1) {
1060
+ const idx = sameTag.indexOf(cur) + 1;
1061
+ part += ':nth-of-type(' + idx + ')';
1062
+ }
1063
+ }
1064
+
1065
+ parts.unshift(part);
1066
+ cur = cur.parentElement;
1067
+ }
1068
+
1069
+ return parts.join(' > ');
1070
+ }
1071
+
1072
+ // Generate selector candidates ordered by quality
1073
+ function getSelectorCandidates(el) {
1074
+ const candidates = [];
1075
+
1076
+ // Get semantic info for role-based selectors
1077
+ const role = getRole(el);
1078
+ const name = getAccessibleName(el);
1079
+
1080
+ // 1. Role + name selector (highest priority for semantic elements)
1081
+ if (role && name) {
1082
+ const escapedName = name.replace(/'/g, "\\\\'");
1083
+ candidates.push({
1084
+ selector: "role=" + role + "[name='" + escapedName + "']",
1085
+ quality: 'role-name'
1086
+ });
1087
+ }
1088
+
1089
+ // 2. Text-based selector (for buttons, links, menuitems)
1090
+ if (name && ['button', 'link', 'menuitem'].includes(role)) {
1091
+ candidates.push({
1092
+ selector: "text=" + name,
1093
+ quality: 'text'
1094
+ });
1095
+ }
1096
+
1097
+ // 3. aria-label attribute selector
1098
+ const ariaLabel = el.getAttribute('aria-label');
1099
+ if (ariaLabel) {
1100
+ const escaped = ariaLabel.replace(/"/g, '\\\\"');
1101
+ candidates.push({
1102
+ selector: '[aria-label="' + escaped + '"]',
1103
+ quality: 'aria-label'
1104
+ });
1105
+ }
1106
+
1107
+ // 4. Stable attributes (testid, name)
1108
+ const stableAttr = getStableAttrSelector(el);
1109
+ if (stableAttr) {
1110
+ candidates.push({ selector: stableAttr, quality: 'stable-attr' });
1111
+ }
1112
+
1113
+ // 5. ID selector
1114
+ const idSel = getIdSelector(el);
1115
+ if (idSel) {
1116
+ candidates.push({ selector: idSel, quality: 'id' });
1117
+ }
1118
+
1119
+ // 6. CSS path (fallback)
1120
+ const cssPath = buildCssPath(el);
1121
+ if (cssPath) {
1122
+ candidates.push({ selector: cssPath, quality: 'css-path' });
1123
+ }
1124
+
1125
+ return candidates;
1126
+ }
1127
+
1128
+ // Compute accessible name per W3C AccName spec
1129
+ // Priority: aria-labelledby > aria-label > label > title > content > alt > placeholder
1130
+ function getAccessibleName(el) {
1131
+ if (!el || el.nodeType !== 1) return null;
1132
+
1133
+ // 1. aria-labelledby
1134
+ const labelledBy = el.getAttribute('aria-labelledby');
1135
+ if (labelledBy) {
1136
+ const labels = labelledBy.split(/\\s+/)
1137
+ .map(function(id) {
1138
+ const ref = document.getElementById(id);
1139
+ return ref ? ref.textContent : null;
1140
+ })
1141
+ .filter(Boolean);
1142
+ if (labels.length) return labels.join(' ').trim().slice(0, 100);
1143
+ }
1144
+
1145
+ // 2. aria-label
1146
+ const ariaLabel = el.getAttribute('aria-label');
1147
+ if (ariaLabel) return ariaLabel.trim().slice(0, 100);
1148
+
1149
+ // 3. Native <label> for form elements
1150
+ if (el.labels && el.labels.length) {
1151
+ const labelTexts = Array.from(el.labels)
1152
+ .map(function(l) { return l.textContent; })
1153
+ .filter(Boolean);
1154
+ if (labelTexts.length) return labelTexts.join(' ').trim().slice(0, 100);
1155
+ }
1156
+
1157
+ // 4. title attribute
1158
+ const title = el.getAttribute('title');
1159
+ if (title) return title.trim().slice(0, 100);
1160
+
1161
+ // 5. Content for buttons, links, summary
1162
+ const tag = el.tagName.toLowerCase();
1163
+ const role = el.getAttribute('role');
1164
+ if (['button', 'a', 'summary'].includes(tag) || role === 'button' || role === 'link' || role === 'menuitem') {
1165
+ const text = (el.textContent || '').trim();
1166
+ if (text) return text.slice(0, 100);
1167
+ }
1168
+
1169
+ // 6. alt for images
1170
+ if (tag === 'img') {
1171
+ const alt = el.getAttribute('alt');
1172
+ if (alt) return alt.trim().slice(0, 100);
1173
+ }
1174
+
1175
+ // 7. placeholder for inputs
1176
+ if (['input', 'textarea'].includes(tag)) {
1177
+ const placeholder = el.getAttribute('placeholder');
1178
+ if (placeholder) return placeholder.trim().slice(0, 100);
1179
+ }
1180
+
1181
+ return null;
1182
+ }
1183
+
1184
+ // Get explicit ARIA role or implicit role from HTML tag
1185
+ function getRole(el) {
1186
+ if (!el || el.nodeType !== 1) return null;
1187
+
1188
+ // 1. Explicit role attribute
1189
+ const explicitRole = el.getAttribute('role');
1190
+ if (explicitRole) return explicitRole;
1191
+
1192
+ // 2. Implicit role from tag/type
1193
+ const tag = el.tagName.toLowerCase();
1194
+ const type = (el.getAttribute('type') || '').toLowerCase();
1195
+
1196
+ // Input types to roles
1197
+ if (tag === 'input') {
1198
+ var inputRoles = {
1199
+ 'button': 'button',
1200
+ 'submit': 'button',
1201
+ 'reset': 'button',
1202
+ 'image': 'button',
1203
+ 'checkbox': 'checkbox',
1204
+ 'radio': 'radio',
1205
+ 'range': 'slider',
1206
+ 'search': 'searchbox'
1207
+ };
1208
+ if (inputRoles[type]) return inputRoles[type];
1209
+ // text, email, tel, url, number, password all map to textbox
1210
+ return 'textbox';
1211
+ }
1212
+
1213
+ // Other tags with implicit roles
1214
+ var tagRoles = {
1215
+ 'button': 'button',
1216
+ 'select': 'combobox',
1217
+ 'textarea': 'textbox',
1218
+ 'nav': 'navigation',
1219
+ 'main': 'main',
1220
+ 'header': 'banner',
1221
+ 'footer': 'contentinfo',
1222
+ 'aside': 'complementary',
1223
+ 'article': 'article',
1224
+ 'ul': 'list',
1225
+ 'ol': 'list',
1226
+ 'li': 'listitem',
1227
+ 'table': 'table',
1228
+ 'tr': 'row',
1229
+ 'td': 'cell',
1230
+ 'th': 'columnheader',
1231
+ 'form': 'form',
1232
+ 'img': 'img',
1233
+ 'dialog': 'dialog',
1234
+ 'menu': 'menu',
1235
+ 'summary': 'button'
1236
+ };
1237
+ if (tagRoles[tag]) return tagRoles[tag];
1238
+
1239
+ // Anchor with href is a link
1240
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
1241
+
1242
+ // Section with aria-label or aria-labelledby is a region
1243
+ if (tag === 'section' && (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby'))) {
1244
+ return 'region';
1245
+ }
1246
+
1247
+ return null;
1248
+ }
1249
+
1250
+ // Get element summary for debugging
1251
+ function getElementSummary(el) {
1252
+ if (!el || el.nodeType !== 1) return null;
1253
+ const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
1254
+ return {
1255
+ tag: el.tagName.toLowerCase(),
1256
+ id: el.id || null,
1257
+ name: el.getAttribute('name') || null,
1258
+ type: el.getAttribute('type') || null,
1259
+ role: el.getAttribute('role') || null,
1260
+ ariaLabel: el.getAttribute('aria-label') || null,
1261
+ testid: el.getAttribute('data-testid') || null,
1262
+ text: text || null,
1263
+ accessibleName: getAccessibleName(el),
1264
+ computedRole: getRole(el)
1265
+ };
1266
+ }
1267
+
1268
+ // Get event target, handling shadow DOM via composedPath
1269
+ function getEventTarget(ev) {
1270
+ const path = ev.composedPath ? ev.composedPath() : null;
1271
+ if (path && path.length > 0) {
1272
+ for (const node of path) {
1273
+ if (node && node.nodeType === 1) return node;
1274
+ }
1275
+ }
1276
+ return ev.target && ev.target.nodeType === 1 ? ev.target : null;
1277
+ }
1278
+
1279
+ // Find clickable ancestor (button, a, [role=button])
1280
+ function findClickableAncestor(el) {
1281
+ if (!el) return el;
1282
+ const clickable = el.closest('button, a, [role="button"], [role="link"]');
1283
+ return clickable || el;
1284
+ }
1285
+
1286
+ // Check if element is a password input
1287
+ function isPasswordInput(el) {
1288
+ if (!el) return false;
1289
+ const tag = el.tagName.toLowerCase();
1290
+ if (tag !== 'input') return false;
1291
+ const type = (el.getAttribute('type') || '').toLowerCase();
1292
+ return type === 'password';
1293
+ }
1294
+
1295
+ // Get input value, redacting passwords
1296
+ function getInputValue(el) {
1297
+ if (isPasswordInput(el)) return '[REDACTED]';
1298
+ if (el.value !== undefined) return el.value;
1299
+ if (el.isContentEditable) return el.textContent || '';
1300
+ return '';
1301
+ }
1302
+
1303
+ // Current timestamp
1304
+ function now() { return Date.now(); }
1305
+
1306
+ // Click handler
1307
+ window.addEventListener('click', function(ev) {
1308
+ const rawTarget = getEventTarget(ev);
1309
+ if (!rawTarget) return;
1310
+
1311
+ // Bubble up to clickable ancestor for better selectors
1312
+ const el = findClickableAncestor(rawTarget);
1313
+
1314
+ sendEvent({
1315
+ kind: 'click',
1316
+ timestamp: now(),
1317
+ url: location.href,
1318
+ element: getElementSummary(el),
1319
+ selectors: getSelectorCandidates(el),
1320
+ client: { x: ev.clientX, y: ev.clientY }
1321
+ });
1322
+ }, true);
1323
+
1324
+ // Double click handler
1325
+ window.addEventListener('dblclick', function(ev) {
1326
+ const rawTarget = getEventTarget(ev);
1327
+ if (!rawTarget) return;
1328
+
1329
+ const el = findClickableAncestor(rawTarget);
1330
+
1331
+ sendEvent({
1332
+ kind: 'dblclick',
1333
+ timestamp: now(),
1334
+ url: location.href,
1335
+ element: getElementSummary(el),
1336
+ selectors: getSelectorCandidates(el),
1337
+ client: { x: ev.clientX, y: ev.clientY }
1338
+ });
1339
+ }, true);
1340
+
1341
+ // Input handler (for text inputs, textareas, contenteditable)
1342
+ window.addEventListener('input', function(ev) {
1343
+ const el = getEventTarget(ev);
1344
+ if (!el) return;
1345
+
1346
+ const tag = el.tagName.toLowerCase();
1347
+ const isTexty = tag === 'input' || tag === 'textarea' || el.isContentEditable;
1348
+ if (!isTexty) return;
1349
+
1350
+ sendEvent({
1351
+ kind: 'input',
1352
+ timestamp: now(),
1353
+ url: location.href,
1354
+ element: getElementSummary(el),
1355
+ selectors: getSelectorCandidates(el),
1356
+ value: getInputValue(el)
1357
+ });
1358
+ }, true);
1359
+
1360
+ // Change handler (for select, checkbox, radio)
1361
+ window.addEventListener('change', function(ev) {
1362
+ const el = getEventTarget(ev);
1363
+ if (!el) return;
1364
+
1365
+ const tag = el.tagName.toLowerCase();
1366
+ const type = (el.getAttribute('type') || '').toLowerCase();
1367
+ const isCheckable = type === 'checkbox' || type === 'radio';
1368
+
1369
+ sendEvent({
1370
+ kind: 'change',
1371
+ timestamp: now(),
1372
+ url: location.href,
1373
+ element: getElementSummary(el),
1374
+ selectors: getSelectorCandidates(el),
1375
+ value: isCheckable ? undefined : getInputValue(el),
1376
+ checked: isCheckable ? el.checked : undefined
1377
+ });
1378
+ }, true);
1379
+
1380
+ // Keydown handler (capture Enter for form submission)
1381
+ window.addEventListener('keydown', function(ev) {
1382
+ if (ev.key !== 'Enter') return;
1383
+
1384
+ const el = getEventTarget(ev);
1385
+
1386
+ sendEvent({
1387
+ kind: 'keydown',
1388
+ timestamp: now(),
1389
+ url: location.href,
1390
+ key: ev.key,
1391
+ element: el ? getElementSummary(el) : null,
1392
+ selectors: el ? getSelectorCandidates(el) : []
1393
+ });
1394
+ }, true);
1395
+
1396
+ // Submit handler
1397
+ window.addEventListener('submit', function(ev) {
1398
+ const el = getEventTarget(ev);
1399
+
1400
+ sendEvent({
1401
+ kind: 'submit',
1402
+ timestamp: now(),
1403
+ url: location.href,
1404
+ element: el ? getElementSummary(el) : null,
1405
+ selectors: el ? getSelectorCandidates(el) : []
1406
+ });
1407
+ }, true);
1408
+ })();`;
1409
+
1410
+ // src/recording/recorder.ts
1411
+ var Recorder = class {
1412
+ cdp;
1413
+ events = [];
1414
+ recording = false;
1415
+ startTime = 0;
1416
+ startUrl = "";
1417
+ bindingHandler = null;
1418
+ constructor(cdp) {
1419
+ this.cdp = cdp;
1420
+ }
1421
+ /**
1422
+ * Check if recording is currently active.
1423
+ */
1424
+ get isRecording() {
1425
+ return this.recording;
1426
+ }
1427
+ /**
1428
+ * Start recording browser interactions.
1429
+ *
1430
+ * Sets up CDP bindings and injects the recorder script into
1431
+ * the current page and all future navigations.
1432
+ */
1433
+ async start() {
1434
+ if (this.recording) {
1435
+ throw new Error("Recording already in progress");
1436
+ }
1437
+ this.events = [];
1438
+ this.startTime = Date.now();
1439
+ this.recording = true;
1440
+ await this.cdp.send("Runtime.enable");
1441
+ await this.cdp.send("Page.enable");
1442
+ try {
1443
+ const result = await this.cdp.send("Runtime.evaluate", {
1444
+ expression: "location.href",
1445
+ returnByValue: true
1446
+ });
1447
+ this.startUrl = result.result.value;
1448
+ } catch {
1449
+ this.startUrl = "";
1450
+ }
1451
+ await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
1452
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1453
+ source: RECORDER_SCRIPT
1454
+ });
1455
+ await this.cdp.send("Runtime.evaluate", {
1456
+ expression: RECORDER_SCRIPT,
1457
+ awaitPromise: false
1458
+ });
1459
+ this.bindingHandler = (params) => {
1460
+ if (params["name"] === RECORDER_BINDING_NAME) {
1461
+ this.handleBindingCall(params["payload"]);
1462
+ }
1463
+ };
1464
+ this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
1465
+ }
1466
+ /**
1467
+ * Stop recording and return aggregated output.
1468
+ *
1469
+ * Returns a RecordingOutput with steps compatible with page.batch().
1470
+ */
1471
+ async stop() {
1472
+ if (!this.recording) {
1473
+ throw new Error("No recording in progress");
1474
+ }
1475
+ this.recording = false;
1476
+ const duration = Date.now() - this.startTime;
1477
+ if (this.bindingHandler) {
1478
+ this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
1479
+ this.bindingHandler = null;
1480
+ }
1481
+ const steps = aggregateEvents(this.events, this.startUrl);
1482
+ return {
1483
+ recordedAt: new Date(this.startTime).toISOString(),
1484
+ startUrl: this.startUrl,
1485
+ duration,
1486
+ steps
1487
+ };
1488
+ }
1489
+ /**
1490
+ * Get raw recorded events (for debugging).
1491
+ */
1492
+ getEvents() {
1493
+ return [...this.events];
1494
+ }
1495
+ /**
1496
+ * Handle incoming binding call from the browser.
1497
+ */
1498
+ handleBindingCall(payload) {
1499
+ if (!this.recording) return;
1500
+ try {
1501
+ const event = JSON.parse(payload);
1502
+ this.events.push(event);
1503
+ } catch {
1504
+ }
1505
+ }
1506
+ };
1507
+
1508
+ // src/cli/commands/record.ts
1509
+ var RECORD_HELP = `
1510
+ bp record - Record browser actions to JSON
1511
+
1512
+ Usage:
1513
+ bp record [options]
1514
+
1515
+ Options:
1516
+ -s, --session [id] Session to use:
1517
+ - omit -s: auto-connect to local browser
1518
+ - -s alone: use most recent session
1519
+ - -s <id>: use specific session
1520
+ -f, --file <path> Output file (default: recording.json)
1521
+ --timeout <ms> Auto-stop after timeout (optional)
1522
+ -h, --help Show this help
1523
+
1524
+ Examples:
1525
+ bp record # Auto-connect to local Chrome
1526
+ bp record -s # Use most recent session
1527
+ bp record -s mysession # Use specific session
1528
+ bp record -f login.json # Save to specific file
1529
+ bp record --timeout 60000 # Auto-stop after 60s
1530
+
1531
+ Recording captures: clicks, inputs, form submissions, navigation.
1532
+ Password fields are automatically redacted as [REDACTED].
1533
+
1534
+ Press Ctrl+C to stop recording and save.
1535
+ `;
1536
+ function parseRecordArgs(args) {
1537
+ const options = {};
1538
+ for (let i = 0; i < args.length; i++) {
1539
+ const arg = args[i];
1540
+ if (arg === "-f" || arg === "--file") {
1541
+ options.file = args[++i];
1542
+ } else if (arg === "--timeout") {
1543
+ options.timeout = Number.parseInt(args[++i] ?? "", 10);
1544
+ } else if (arg === "-h" || arg === "--help") {
1545
+ options.help = true;
1546
+ } else if (arg === "-s" || arg === "--session") {
1547
+ const nextArg = args[i + 1];
1548
+ if (!nextArg || nextArg.startsWith("-")) {
1549
+ options.useLatestSession = true;
1550
+ }
1551
+ }
1552
+ }
1553
+ return options;
1554
+ }
1555
+ async function resolveConnection(sessionId, useLatestSession, trace) {
1556
+ if (sessionId) {
1557
+ const session2 = await loadSession(sessionId);
1558
+ const browser2 = await connect({
1559
+ provider: session2.provider,
1560
+ wsUrl: session2.wsUrl,
1561
+ debug: trace
1562
+ });
1563
+ return { browser: browser2, session: session2, isNewSession: false };
1564
+ }
1565
+ if (useLatestSession) {
1566
+ const session2 = await getDefaultSession();
1567
+ if (!session2) {
1568
+ throw new Error(
1569
+ 'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
1570
+ );
1571
+ }
1572
+ const browser2 = await connect({
1573
+ provider: session2.provider,
1574
+ wsUrl: session2.wsUrl,
1575
+ debug: trace
1576
+ });
1577
+ return { browser: browser2, session: session2, isNewSession: false };
1578
+ }
1579
+ let wsUrl;
1580
+ try {
1581
+ wsUrl = await getBrowserWebSocketUrl("localhost:9222");
1582
+ } catch {
1583
+ throw new Error(
1584
+ "Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp record -s <session-id>\n 3. Use latest session: bp record -s"
1585
+ );
1586
+ }
1587
+ const browser = await connect({
1588
+ provider: "generic",
1589
+ wsUrl,
1590
+ debug: trace
1591
+ });
1592
+ const page = await browser.page();
1593
+ const currentUrl = await page.url();
1594
+ const newSessionId = generateSessionId();
1595
+ const session = {
1596
+ id: newSessionId,
1597
+ provider: "generic",
1598
+ wsUrl: browser.wsUrl,
1599
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1600
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1601
+ currentUrl
1602
+ };
1603
+ await saveSession(session);
1604
+ return { browser, session, isNewSession: true };
1605
+ }
1606
+ async function recordCommand(args, globalOptions) {
1607
+ const options = parseRecordArgs(args);
1608
+ if (options.help || globalOptions.help) {
1609
+ console.log(RECORD_HELP);
1610
+ return;
1611
+ }
1612
+ const outputFile = options.file ?? "recording.json";
1613
+ const { browser, session, isNewSession } = await resolveConnection(
1614
+ globalOptions.session,
1615
+ options.useLatestSession ?? false,
1616
+ globalOptions.trace ?? false
1617
+ );
1618
+ if (isNewSession) {
1619
+ console.log(`Created new session: ${session.id}`);
1620
+ }
1621
+ const page = await browser.page();
1622
+ const cdp = page.cdpClient;
1623
+ const recorder = new Recorder(cdp);
1624
+ let stopping = false;
1625
+ async function stopAndSave() {
1626
+ if (stopping) return;
1627
+ stopping = true;
1628
+ try {
1629
+ const recording = await recorder.stop();
1630
+ const fs = await import("fs/promises");
1631
+ await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
1632
+ const currentUrl = await page.url();
1633
+ await updateSession(session.id, { currentUrl });
1634
+ await browser.disconnect();
1635
+ console.log(`
1636
+ Saved ${recording.steps.length} steps to ${outputFile}`);
1637
+ if (globalOptions.output === "json") {
1638
+ output(
1639
+ {
1640
+ success: true,
1641
+ file: outputFile,
1642
+ steps: recording.steps.length,
1643
+ duration: recording.duration
1644
+ },
1645
+ "json"
1646
+ );
1647
+ }
1648
+ process.exit(0);
1649
+ } catch (error) {
1650
+ console.error("Error saving recording:", error);
1651
+ process.exit(1);
1652
+ }
1653
+ }
1654
+ process.on("SIGINT", stopAndSave);
1655
+ process.on("SIGTERM", stopAndSave);
1656
+ if (options.timeout && options.timeout > 0) {
1657
+ setTimeout(stopAndSave, options.timeout);
1658
+ }
1659
+ await recorder.start();
1660
+ console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
1661
+ console.log(`Session: ${session.id}`);
1662
+ console.log(`URL: ${await page.url()}`);
1663
+ }
1664
+
670
1665
  // src/cli/commands/screenshot.ts
671
1666
  function parseScreenshotArgs(args) {
672
1667
  const options = {};
@@ -840,6 +1835,7 @@ Commands:
840
1835
  quickstart Getting started guide (start here!)
841
1836
  connect Create browser session
842
1837
  exec Execute actions
1838
+ record Record browser actions to JSON
843
1839
  snapshot Get page with element refs
844
1840
  text Extract text content
845
1841
  screenshot Take screenshot
@@ -860,6 +1856,8 @@ Examples:
860
1856
  bp exec '{"action":"goto","url":"https://example.com"}'
861
1857
  bp snapshot --format text
862
1858
  bp exec '{"action":"click","selector":"ref:e3"}'
1859
+ bp record # Record from local browser
1860
+ bp record -s -f login.json # Record from latest session
863
1861
 
864
1862
  Run 'bp quickstart' for CLI workflow guide.
865
1863
  Run 'bp actions' for complete action reference.
@@ -962,6 +1960,9 @@ async function main() {
962
1960
  case "actions":
963
1961
  await actionsCommand();
964
1962
  break;
1963
+ case "record":
1964
+ await recordCommand(remaining, options);
1965
+ break;
965
1966
  case "help":
966
1967
  case "--help":
967
1968
  case "-h":