browser-pilot 0.0.4 → 0.0.6

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.cjs CHANGED
@@ -214,6 +214,142 @@ async function actionsCommand() {
214
214
  console.log(ACTIONS_HELP);
215
215
  }
216
216
 
217
+ // src/cli/session.ts
218
+ var import_node_os = require("os");
219
+ var import_node_path = require("path");
220
+ var SESSION_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".browser-pilot", "sessions");
221
+ async function ensureSessionDir() {
222
+ const fs = await import("fs/promises");
223
+ await fs.mkdir(SESSION_DIR, { recursive: true });
224
+ }
225
+ async function saveSession(session) {
226
+ await ensureSessionDir();
227
+ const fs = await import("fs/promises");
228
+ const filePath = (0, import_node_path.join)(SESSION_DIR, `${session.id}.json`);
229
+ await fs.writeFile(filePath, JSON.stringify(session, null, 2));
230
+ }
231
+ async function loadSession(id) {
232
+ const fs = await import("fs/promises");
233
+ const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
234
+ try {
235
+ const content = await fs.readFile(filePath, "utf-8");
236
+ return JSON.parse(content);
237
+ } catch (error) {
238
+ if (error.code === "ENOENT") {
239
+ throw new Error(`Session not found: ${id}`);
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+ async function updateSession(id, updates) {
245
+ const session = await loadSession(id);
246
+ const mergedMetadata = updates.metadata !== void 0 ? { ...session.metadata ?? {}, ...updates.metadata ?? {} } : session.metadata;
247
+ const updated = {
248
+ ...session,
249
+ ...updates,
250
+ metadata: mergedMetadata,
251
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString()
252
+ };
253
+ await saveSession(updated);
254
+ return updated;
255
+ }
256
+ async function deleteSession(id) {
257
+ const fs = await import("fs/promises");
258
+ const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
259
+ try {
260
+ await fs.unlink(filePath);
261
+ } catch (error) {
262
+ if (error.code !== "ENOENT") {
263
+ throw error;
264
+ }
265
+ }
266
+ }
267
+ async function listSessions() {
268
+ await ensureSessionDir();
269
+ const fs = await import("fs/promises");
270
+ try {
271
+ const files = await fs.readdir(SESSION_DIR);
272
+ const sessions = [];
273
+ for (const file of files) {
274
+ if (file.endsWith(".json")) {
275
+ try {
276
+ const content = await fs.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
277
+ sessions.push(JSON.parse(content));
278
+ } catch {
279
+ }
280
+ }
281
+ }
282
+ return sessions.sort(
283
+ (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
284
+ );
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+ function generateSessionId() {
290
+ const timestamp = Date.now().toString(36);
291
+ const random = Math.random().toString(36).slice(2, 8);
292
+ return `${timestamp}-${random}`;
293
+ }
294
+ async function getDefaultSession() {
295
+ const sessions = await listSessions();
296
+ return sessions[0] ?? null;
297
+ }
298
+
299
+ // src/cli/commands/clean.ts
300
+ function parseCleanArgs(args) {
301
+ const options = {};
302
+ for (let i = 0; i < args.length; i++) {
303
+ const arg = args[i];
304
+ if (arg === "--max-age") {
305
+ const value = args[++i];
306
+ options.maxAge = parseInt(value ?? "24", 10);
307
+ } else if (arg === "--dry-run") {
308
+ options.dryRun = true;
309
+ } else if (arg === "--all") {
310
+ options.all = true;
311
+ }
312
+ }
313
+ return options;
314
+ }
315
+ async function cleanCommand(args, globalOptions) {
316
+ const options = parseCleanArgs(args);
317
+ const maxAgeMs = (options.maxAge ?? 24) * 60 * 60 * 1e3;
318
+ const now = Date.now();
319
+ const sessions = await listSessions();
320
+ const stale = sessions.filter((s) => {
321
+ if (options.all) return true;
322
+ const age = now - new Date(s.lastActivity).getTime();
323
+ return age > maxAgeMs;
324
+ });
325
+ if (stale.length === 0) {
326
+ output({ message: "No stale sessions found", cleaned: 0 }, globalOptions.output);
327
+ return;
328
+ }
329
+ if (options.dryRun) {
330
+ output(
331
+ {
332
+ message: `Would clean ${stale.length} session(s)`,
333
+ sessions: stale.map((s) => s.id),
334
+ dryRun: true
335
+ },
336
+ globalOptions.output
337
+ );
338
+ return;
339
+ }
340
+ for (const session of stale) {
341
+ await deleteSession(session.id);
342
+ }
343
+ output(
344
+ {
345
+ message: `Cleaned ${stale.length} session(s)`,
346
+ cleaned: stale.length,
347
+ sessions: stale.map((s) => s.id)
348
+ },
349
+ globalOptions.output
350
+ );
351
+ }
352
+
217
353
  // src/actions/executor.ts
218
354
  var DEFAULT_TIMEOUT = 3e4;
219
355
  var BatchExecutor = class {
@@ -299,7 +435,8 @@ var BatchExecutor = class {
299
435
  await this.page.fill(step.selector, step.value, {
300
436
  timeout,
301
437
  optional,
302
- clear: step.clear ?? true
438
+ clear: step.clear ?? true,
439
+ blur: step.blur
303
440
  });
304
441
  return { selectorUsed: this.getUsedSelector(step.selector) };
305
442
  }
@@ -1327,6 +1464,13 @@ var Page = class {
1327
1464
  this.cdp = cdp;
1328
1465
  this.batchExecutor = new BatchExecutor(this);
1329
1466
  }
1467
+ /**
1468
+ * Get the underlying CDP client for advanced operations.
1469
+ * Use with caution - prefer high-level Page methods when possible.
1470
+ */
1471
+ get cdpClient() {
1472
+ return this.cdp;
1473
+ }
1330
1474
  /**
1331
1475
  * Initialize the page (enable required CDP domains)
1332
1476
  */
@@ -1477,7 +1621,7 @@ var Page = class {
1477
1621
  * Fill an input field (clears first by default)
1478
1622
  */
1479
1623
  async fill(selector, value, options = {}) {
1480
- const { clear = true } = options;
1624
+ const { clear = true, blur = false } = options;
1481
1625
  return this.withStaleNodeRetry(async () => {
1482
1626
  const element = await this.findElement(selector, options);
1483
1627
  if (!element) {
@@ -1491,7 +1635,11 @@ var Page = class {
1491
1635
  const el = document.querySelector(${JSON.stringify(element.selector)});
1492
1636
  if (el) {
1493
1637
  el.value = '';
1494
- el.dispatchEvent(new Event('input', { bubbles: true }));
1638
+ el.dispatchEvent(new InputEvent('input', {
1639
+ bubbles: true,
1640
+ cancelable: true,
1641
+ inputType: 'deleteContent'
1642
+ }));
1495
1643
  }
1496
1644
  })()`
1497
1645
  );
@@ -1501,11 +1649,21 @@ var Page = class {
1501
1649
  `(() => {
1502
1650
  const el = document.querySelector(${JSON.stringify(element.selector)});
1503
1651
  if (el) {
1504
- el.dispatchEvent(new Event('input', { bubbles: true }));
1652
+ el.dispatchEvent(new InputEvent('input', {
1653
+ bubbles: true,
1654
+ cancelable: true,
1655
+ inputType: 'insertText',
1656
+ data: ${JSON.stringify(value)}
1657
+ }));
1505
1658
  el.dispatchEvent(new Event('change', { bubbles: true }));
1506
1659
  }
1507
1660
  })()`
1508
1661
  );
1662
+ if (blur) {
1663
+ await this.evaluateInFrame(
1664
+ `document.querySelector(${JSON.stringify(element.selector)})?.blur()`
1665
+ );
1666
+ }
1509
1667
  return true;
1510
1668
  });
1511
1669
  }
@@ -2974,88 +3132,6 @@ function connect(options) {
2974
3132
  return Browser.connect(options);
2975
3133
  }
2976
3134
 
2977
- // src/cli/session.ts
2978
- var import_node_os = require("os");
2979
- var import_node_path = require("path");
2980
- var SESSION_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".browser-pilot", "sessions");
2981
- async function ensureSessionDir() {
2982
- const fs = await import("fs/promises");
2983
- await fs.mkdir(SESSION_DIR, { recursive: true });
2984
- }
2985
- async function saveSession(session) {
2986
- await ensureSessionDir();
2987
- const fs = await import("fs/promises");
2988
- const filePath = (0, import_node_path.join)(SESSION_DIR, `${session.id}.json`);
2989
- await fs.writeFile(filePath, JSON.stringify(session, null, 2));
2990
- }
2991
- async function loadSession(id) {
2992
- const fs = await import("fs/promises");
2993
- const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
2994
- try {
2995
- const content = await fs.readFile(filePath, "utf-8");
2996
- return JSON.parse(content);
2997
- } catch (error) {
2998
- if (error.code === "ENOENT") {
2999
- throw new Error(`Session not found: ${id}`);
3000
- }
3001
- throw error;
3002
- }
3003
- }
3004
- async function updateSession(id, updates) {
3005
- const session = await loadSession(id);
3006
- const mergedMetadata = updates.metadata !== void 0 ? { ...session.metadata ?? {}, ...updates.metadata ?? {} } : session.metadata;
3007
- const updated = {
3008
- ...session,
3009
- ...updates,
3010
- metadata: mergedMetadata,
3011
- lastActivity: (/* @__PURE__ */ new Date()).toISOString()
3012
- };
3013
- await saveSession(updated);
3014
- return updated;
3015
- }
3016
- async function deleteSession(id) {
3017
- const fs = await import("fs/promises");
3018
- const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
3019
- try {
3020
- await fs.unlink(filePath);
3021
- } catch (error) {
3022
- if (error.code !== "ENOENT") {
3023
- throw error;
3024
- }
3025
- }
3026
- }
3027
- async function listSessions() {
3028
- await ensureSessionDir();
3029
- const fs = await import("fs/promises");
3030
- try {
3031
- const files = await fs.readdir(SESSION_DIR);
3032
- const sessions = [];
3033
- for (const file of files) {
3034
- if (file.endsWith(".json")) {
3035
- try {
3036
- const content = await fs.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
3037
- sessions.push(JSON.parse(content));
3038
- } catch {
3039
- }
3040
- }
3041
- }
3042
- return sessions.sort(
3043
- (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
3044
- );
3045
- } catch {
3046
- return [];
3047
- }
3048
- }
3049
- function generateSessionId() {
3050
- const timestamp = Date.now().toString(36);
3051
- const random = Math.random().toString(36).slice(2, 8);
3052
- return `${timestamp}-${random}`;
3053
- }
3054
- async function getDefaultSession() {
3055
- const sessions = await listSessions();
3056
- return sessions[0] ?? null;
3057
- }
3058
-
3059
3135
  // src/cli/commands/close.ts
3060
3136
  async function closeCommand(args, globalOptions) {
3061
3137
  let session;
@@ -3174,6 +3250,17 @@ async function connectCommand(args, globalOptions) {
3174
3250
  }
3175
3251
 
3176
3252
  // src/cli/commands/exec.ts
3253
+ async function validateSession(session) {
3254
+ try {
3255
+ const wsUrl = new URL(session.wsUrl);
3256
+ const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
3257
+ const httpUrl = `${protocol}//${wsUrl.host}/json/version`;
3258
+ const response = await fetch(httpUrl, { signal: AbortSignal.timeout(3e3) });
3259
+ return response.ok;
3260
+ } catch {
3261
+ return false;
3262
+ }
3263
+ }
3177
3264
  function parseExecArgs(args) {
3178
3265
  const options = {};
3179
3266
  let actionsJson;
@@ -3218,6 +3305,14 @@ Run 'bp actions' for complete action reference.`
3218
3305
  throw new Error('No session found. Run "bp connect" first.');
3219
3306
  }
3220
3307
  }
3308
+ const isValid = await validateSession(session);
3309
+ if (!isValid) {
3310
+ await deleteSession(session.id);
3311
+ throw new Error(
3312
+ `Session "${session.id}" is no longer valid (browser may have closed).
3313
+ Session file has been cleaned up. Run "bp connect" to create a new session.`
3314
+ );
3315
+ }
3221
3316
  const browser = await connect({
3222
3317
  provider: session.provider,
3223
3318
  wsUrl: session.wsUrl,
@@ -3372,12 +3467,760 @@ COMMON ACTIONS
3372
3467
  snapshot {"action":"snapshot"}
3373
3468
  screenshot {"action":"screenshot"}
3374
3469
 
3470
+ RECORDING (FOR HUMANS)
3471
+ Want to create automations by demonstrating instead of coding?
3472
+ Use 'bp record' to capture your browser interactions as replayable JSON:
3473
+
3474
+ bp record # Record from local Chrome
3475
+ bp exec --file login.json # Replay the recording
3476
+
3477
+ Great for creating initial automation scripts that AI agents can refine.
3478
+
3375
3479
  Run 'bp actions' for the complete action reference.
3376
3480
  `;
3377
3481
  async function quickstartCommand() {
3378
3482
  console.log(QUICKSTART);
3379
3483
  }
3380
3484
 
3485
+ // src/recording/aggregator.ts
3486
+ var INPUT_DEBOUNCE_MS = 300;
3487
+ var NAVIGATION_DEBOUNCE_MS = 500;
3488
+ function selectBestSelectors(candidates) {
3489
+ const qualityOrder = {
3490
+ "stable-attr": 0,
3491
+ id: 1,
3492
+ "css-path": 2
3493
+ };
3494
+ const sorted = [...candidates].sort((a, b) => {
3495
+ const aOrder = qualityOrder[a.quality] ?? 3;
3496
+ const bOrder = qualityOrder[b.quality] ?? 3;
3497
+ return aOrder - bOrder;
3498
+ });
3499
+ const seen = /* @__PURE__ */ new Set();
3500
+ const result = [];
3501
+ for (const candidate of sorted) {
3502
+ if (!seen.has(candidate.selector)) {
3503
+ seen.add(candidate.selector);
3504
+ result.push(candidate.selector);
3505
+ }
3506
+ }
3507
+ return result;
3508
+ }
3509
+ function debounceInputEvents(events) {
3510
+ const result = [];
3511
+ for (let i = 0; i < events.length; i++) {
3512
+ const event = events[i];
3513
+ if (event.kind !== "input") {
3514
+ result.push(event);
3515
+ continue;
3516
+ }
3517
+ const primarySelector = event.selectors[0]?.selector;
3518
+ if (!primarySelector) {
3519
+ result.push(event);
3520
+ continue;
3521
+ }
3522
+ let finalEvent = event;
3523
+ let j = i + 1;
3524
+ while (j < events.length) {
3525
+ const nextEvent = events[j];
3526
+ if (nextEvent.timestamp - finalEvent.timestamp > INPUT_DEBOUNCE_MS) {
3527
+ break;
3528
+ }
3529
+ if (nextEvent.kind !== "input") {
3530
+ break;
3531
+ }
3532
+ const nextPrimarySelector = nextEvent.selectors[0]?.selector;
3533
+ if (nextPrimarySelector !== primarySelector) {
3534
+ break;
3535
+ }
3536
+ finalEvent = nextEvent;
3537
+ j++;
3538
+ }
3539
+ i = j - 1;
3540
+ result.push(finalEvent);
3541
+ }
3542
+ return result;
3543
+ }
3544
+ function debounceNavigationEvents(events) {
3545
+ const result = [];
3546
+ for (let i = 0; i < events.length; i++) {
3547
+ const event = events[i];
3548
+ if (event.kind !== "navigation") {
3549
+ result.push(event);
3550
+ continue;
3551
+ }
3552
+ let finalEvent = event;
3553
+ let j = i + 1;
3554
+ while (j < events.length) {
3555
+ const nextEvent = events[j];
3556
+ if (nextEvent.timestamp - finalEvent.timestamp > NAVIGATION_DEBOUNCE_MS) {
3557
+ break;
3558
+ }
3559
+ if (nextEvent.kind !== "navigation") {
3560
+ break;
3561
+ }
3562
+ finalEvent = nextEvent;
3563
+ j++;
3564
+ }
3565
+ i = j - 1;
3566
+ result.push(finalEvent);
3567
+ }
3568
+ return result;
3569
+ }
3570
+ function insertNavigationSteps(events, startUrl) {
3571
+ const result = [];
3572
+ let lastUrl = startUrl || null;
3573
+ for (const event of events) {
3574
+ if (lastUrl !== null && event.url !== lastUrl) {
3575
+ result.push({
3576
+ kind: "navigation",
3577
+ timestamp: event.timestamp,
3578
+ url: event.url,
3579
+ selectors: []
3580
+ });
3581
+ }
3582
+ result.push(event);
3583
+ lastUrl = event.url;
3584
+ }
3585
+ return result;
3586
+ }
3587
+ function eventToStep(event) {
3588
+ const selectors = selectBestSelectors(event.selectors);
3589
+ switch (event.kind) {
3590
+ case "click":
3591
+ case "dblclick":
3592
+ if (selectors.length === 0) return null;
3593
+ return {
3594
+ action: "click",
3595
+ selector: selectors.length === 1 ? selectors[0] : selectors
3596
+ };
3597
+ case "input":
3598
+ if (selectors.length === 0) return null;
3599
+ return {
3600
+ action: "fill",
3601
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3602
+ value: event.value ?? ""
3603
+ };
3604
+ case "change": {
3605
+ if (selectors.length === 0) return null;
3606
+ const element = event.element;
3607
+ const tag = element?.tag;
3608
+ const type = element?.type?.toLowerCase();
3609
+ if (tag === "select") {
3610
+ return {
3611
+ action: "select",
3612
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3613
+ value: event.value ?? ""
3614
+ };
3615
+ }
3616
+ if (type === "checkbox" || type === "radio") {
3617
+ return {
3618
+ action: event.checked ? "check" : "uncheck",
3619
+ selector: selectors.length === 1 ? selectors[0] : selectors
3620
+ };
3621
+ }
3622
+ return {
3623
+ action: "fill",
3624
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3625
+ value: event.value ?? ""
3626
+ };
3627
+ }
3628
+ case "keydown":
3629
+ if (event.key === "Enter") {
3630
+ if (selectors.length === 0) return null;
3631
+ return {
3632
+ action: "submit",
3633
+ selector: selectors.length === 1 ? selectors[0] : selectors,
3634
+ method: "enter"
3635
+ };
3636
+ }
3637
+ return null;
3638
+ case "submit":
3639
+ if (selectors.length === 0) return null;
3640
+ return {
3641
+ action: "submit",
3642
+ selector: selectors.length === 1 ? selectors[0] : selectors
3643
+ };
3644
+ case "navigation":
3645
+ return {
3646
+ action: "goto",
3647
+ url: event.url
3648
+ };
3649
+ default:
3650
+ return null;
3651
+ }
3652
+ }
3653
+ function deduplicateSteps(steps) {
3654
+ const result = [];
3655
+ for (let i = 0; i < steps.length; i++) {
3656
+ const step = steps[i];
3657
+ const prevStep = result[result.length - 1];
3658
+ if (step.action === "submit" && prevStep?.action === "submit" && JSON.stringify(step.selector) === JSON.stringify(prevStep.selector)) {
3659
+ continue;
3660
+ }
3661
+ result.push(step);
3662
+ }
3663
+ return result;
3664
+ }
3665
+ function aggregateEvents(events, startUrl) {
3666
+ if (events.length === 0) return [];
3667
+ let processed = insertNavigationSteps(events, startUrl);
3668
+ processed = debounceNavigationEvents(processed);
3669
+ processed = debounceInputEvents(processed);
3670
+ const steps = [];
3671
+ for (const event of processed) {
3672
+ const step = eventToStep(event);
3673
+ if (step) {
3674
+ steps.push(step);
3675
+ }
3676
+ }
3677
+ return deduplicateSteps(steps);
3678
+ }
3679
+
3680
+ // src/recording/script.ts
3681
+ var RECORDER_BINDING_NAME = "__recorder";
3682
+ var RECORDER_SCRIPT = `(function() {
3683
+ // Guard against multiple installations
3684
+ if (window.__recorderInstalled) return;
3685
+ window.__recorderInstalled = true;
3686
+
3687
+ const BINDING_NAME = '__recorder';
3688
+
3689
+ // Safe JSON stringify
3690
+ function safeJson(obj) {
3691
+ try {
3692
+ return JSON.stringify(obj);
3693
+ } catch (e) {
3694
+ return JSON.stringify({ error: 'unserializable' });
3695
+ }
3696
+ }
3697
+
3698
+ // Send event to CDP client via binding
3699
+ function sendEvent(payload) {
3700
+ try {
3701
+ if (typeof window[BINDING_NAME] === 'function') {
3702
+ window[BINDING_NAME](safeJson(payload));
3703
+ }
3704
+ } catch (e) {
3705
+ // Binding not ready, ignore
3706
+ }
3707
+ }
3708
+
3709
+ // CSS escape for identifiers
3710
+ function cssEscape(str) {
3711
+ return String(str).replace(/([\\[\\]#.:>+~=|^$*!"'(){}])/g, '\\\\$1');
3712
+ }
3713
+
3714
+ // Check if selector is unique in document
3715
+ function isUnique(selector, root) {
3716
+ try {
3717
+ return (root || document).querySelectorAll(selector).length === 1;
3718
+ } catch (e) {
3719
+ return false;
3720
+ }
3721
+ }
3722
+
3723
+ // Get stable attribute selector (data-testid, aria-label, name, etc.)
3724
+ function getStableAttrSelector(el) {
3725
+ if (!el || el.nodeType !== 1) return null;
3726
+ const attrs = ['data-testid', 'data-test', 'data-qa', 'aria-label', 'name'];
3727
+ for (const attr of attrs) {
3728
+ const val = el.getAttribute(attr);
3729
+ if (val && val.length <= 200) {
3730
+ const escaped = val.replace(/"/g, '\\\\"');
3731
+ return '[' + attr + '="' + escaped + '"]';
3732
+ }
3733
+ }
3734
+ return null;
3735
+ }
3736
+
3737
+ // Get ID selector
3738
+ function getIdSelector(el) {
3739
+ if (!el || !el.id || el.id.length > 100) return null;
3740
+ // Skip dynamic-looking IDs
3741
+ if (/^[0-9]|^:/.test(el.id)) return null;
3742
+ return '#' + cssEscape(el.id);
3743
+ }
3744
+
3745
+ // Build CSS path for element
3746
+ function buildCssPath(el) {
3747
+ if (!el || el.nodeType !== 1) return null;
3748
+ const parts = [];
3749
+ let cur = el;
3750
+
3751
+ for (let depth = 0; cur && cur !== document.body && depth < 8; depth++) {
3752
+ let part = cur.tagName.toLowerCase();
3753
+
3754
+ // If ID exists and looks stable, use it and stop
3755
+ if (cur.id && !/^[0-9]|^:/.test(cur.id) && cur.id.length <= 50) {
3756
+ part = '#' + cssEscape(cur.id);
3757
+ parts.unshift(part);
3758
+ break;
3759
+ }
3760
+
3761
+ // Add stable classes (skip dynamic ones)
3762
+ const classes = Array.from(cur.classList || [])
3763
+ .filter(c => c.length < 40 && !/^css-|^_|^[0-9]/.test(c))
3764
+ .slice(0, 2);
3765
+ if (classes.length) {
3766
+ part += '.' + classes.map(cssEscape).join('.');
3767
+ }
3768
+
3769
+ // Add position if siblings have same tag
3770
+ const parent = cur.parentElement;
3771
+ if (parent) {
3772
+ const sameTag = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
3773
+ if (sameTag.length > 1) {
3774
+ const idx = sameTag.indexOf(cur) + 1;
3775
+ part += ':nth-of-type(' + idx + ')';
3776
+ }
3777
+ }
3778
+
3779
+ parts.unshift(part);
3780
+ cur = cur.parentElement;
3781
+ }
3782
+
3783
+ return parts.join(' > ');
3784
+ }
3785
+
3786
+ // Generate selector candidates ordered by quality
3787
+ function getSelectorCandidates(el) {
3788
+ const candidates = [];
3789
+
3790
+ // 1. Stable attributes (highest quality)
3791
+ const stableAttr = getStableAttrSelector(el);
3792
+ if (stableAttr) {
3793
+ candidates.push({ selector: stableAttr, quality: 'stable-attr' });
3794
+ }
3795
+
3796
+ // 2. ID selector
3797
+ const idSel = getIdSelector(el);
3798
+ if (idSel) {
3799
+ candidates.push({ selector: idSel, quality: 'id' });
3800
+ }
3801
+
3802
+ // 3. CSS path (fallback)
3803
+ const cssPath = buildCssPath(el);
3804
+ if (cssPath) {
3805
+ candidates.push({ selector: cssPath, quality: 'css-path' });
3806
+ }
3807
+
3808
+ return candidates;
3809
+ }
3810
+
3811
+ // Get element summary for debugging
3812
+ function getElementSummary(el) {
3813
+ if (!el || el.nodeType !== 1) return null;
3814
+ const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
3815
+ return {
3816
+ tag: el.tagName.toLowerCase(),
3817
+ id: el.id || null,
3818
+ name: el.getAttribute('name') || null,
3819
+ type: el.getAttribute('type') || null,
3820
+ role: el.getAttribute('role') || null,
3821
+ ariaLabel: el.getAttribute('aria-label') || null,
3822
+ testid: el.getAttribute('data-testid') || null,
3823
+ text: text || null
3824
+ };
3825
+ }
3826
+
3827
+ // Get event target, handling shadow DOM via composedPath
3828
+ function getEventTarget(ev) {
3829
+ const path = ev.composedPath ? ev.composedPath() : null;
3830
+ if (path && path.length > 0) {
3831
+ for (const node of path) {
3832
+ if (node && node.nodeType === 1) return node;
3833
+ }
3834
+ }
3835
+ return ev.target && ev.target.nodeType === 1 ? ev.target : null;
3836
+ }
3837
+
3838
+ // Find clickable ancestor (button, a, [role=button])
3839
+ function findClickableAncestor(el) {
3840
+ if (!el) return el;
3841
+ const clickable = el.closest('button, a, [role="button"], [role="link"]');
3842
+ return clickable || el;
3843
+ }
3844
+
3845
+ // Check if element is a password input
3846
+ function isPasswordInput(el) {
3847
+ if (!el) return false;
3848
+ const tag = el.tagName.toLowerCase();
3849
+ if (tag !== 'input') return false;
3850
+ const type = (el.getAttribute('type') || '').toLowerCase();
3851
+ return type === 'password';
3852
+ }
3853
+
3854
+ // Get input value, redacting passwords
3855
+ function getInputValue(el) {
3856
+ if (isPasswordInput(el)) return '[REDACTED]';
3857
+ if (el.value !== undefined) return el.value;
3858
+ if (el.isContentEditable) return el.textContent || '';
3859
+ return '';
3860
+ }
3861
+
3862
+ // Current timestamp
3863
+ function now() { return Date.now(); }
3864
+
3865
+ // Click handler
3866
+ window.addEventListener('click', function(ev) {
3867
+ const rawTarget = getEventTarget(ev);
3868
+ if (!rawTarget) return;
3869
+
3870
+ // Bubble up to clickable ancestor for better selectors
3871
+ const el = findClickableAncestor(rawTarget);
3872
+
3873
+ sendEvent({
3874
+ kind: 'click',
3875
+ timestamp: now(),
3876
+ url: location.href,
3877
+ element: getElementSummary(el),
3878
+ selectors: getSelectorCandidates(el),
3879
+ client: { x: ev.clientX, y: ev.clientY }
3880
+ });
3881
+ }, true);
3882
+
3883
+ // Double click handler
3884
+ window.addEventListener('dblclick', function(ev) {
3885
+ const rawTarget = getEventTarget(ev);
3886
+ if (!rawTarget) return;
3887
+
3888
+ const el = findClickableAncestor(rawTarget);
3889
+
3890
+ sendEvent({
3891
+ kind: 'dblclick',
3892
+ timestamp: now(),
3893
+ url: location.href,
3894
+ element: getElementSummary(el),
3895
+ selectors: getSelectorCandidates(el),
3896
+ client: { x: ev.clientX, y: ev.clientY }
3897
+ });
3898
+ }, true);
3899
+
3900
+ // Input handler (for text inputs, textareas, contenteditable)
3901
+ window.addEventListener('input', function(ev) {
3902
+ const el = getEventTarget(ev);
3903
+ if (!el) return;
3904
+
3905
+ const tag = el.tagName.toLowerCase();
3906
+ const isTexty = tag === 'input' || tag === 'textarea' || el.isContentEditable;
3907
+ if (!isTexty) return;
3908
+
3909
+ sendEvent({
3910
+ kind: 'input',
3911
+ timestamp: now(),
3912
+ url: location.href,
3913
+ element: getElementSummary(el),
3914
+ selectors: getSelectorCandidates(el),
3915
+ value: getInputValue(el)
3916
+ });
3917
+ }, true);
3918
+
3919
+ // Change handler (for select, checkbox, radio)
3920
+ window.addEventListener('change', function(ev) {
3921
+ const el = getEventTarget(ev);
3922
+ if (!el) return;
3923
+
3924
+ const tag = el.tagName.toLowerCase();
3925
+ const type = (el.getAttribute('type') || '').toLowerCase();
3926
+ const isCheckable = type === 'checkbox' || type === 'radio';
3927
+
3928
+ sendEvent({
3929
+ kind: 'change',
3930
+ timestamp: now(),
3931
+ url: location.href,
3932
+ element: getElementSummary(el),
3933
+ selectors: getSelectorCandidates(el),
3934
+ value: isCheckable ? undefined : getInputValue(el),
3935
+ checked: isCheckable ? el.checked : undefined
3936
+ });
3937
+ }, true);
3938
+
3939
+ // Keydown handler (capture Enter for form submission)
3940
+ window.addEventListener('keydown', function(ev) {
3941
+ if (ev.key !== 'Enter') return;
3942
+
3943
+ const el = getEventTarget(ev);
3944
+
3945
+ sendEvent({
3946
+ kind: 'keydown',
3947
+ timestamp: now(),
3948
+ url: location.href,
3949
+ key: ev.key,
3950
+ element: el ? getElementSummary(el) : null,
3951
+ selectors: el ? getSelectorCandidates(el) : []
3952
+ });
3953
+ }, true);
3954
+
3955
+ // Submit handler
3956
+ window.addEventListener('submit', function(ev) {
3957
+ const el = getEventTarget(ev);
3958
+
3959
+ sendEvent({
3960
+ kind: 'submit',
3961
+ timestamp: now(),
3962
+ url: location.href,
3963
+ element: el ? getElementSummary(el) : null,
3964
+ selectors: el ? getSelectorCandidates(el) : []
3965
+ });
3966
+ }, true);
3967
+ })();`;
3968
+
3969
+ // src/recording/recorder.ts
3970
+ var Recorder = class {
3971
+ cdp;
3972
+ events = [];
3973
+ recording = false;
3974
+ startTime = 0;
3975
+ startUrl = "";
3976
+ bindingHandler = null;
3977
+ constructor(cdp) {
3978
+ this.cdp = cdp;
3979
+ }
3980
+ /**
3981
+ * Check if recording is currently active.
3982
+ */
3983
+ get isRecording() {
3984
+ return this.recording;
3985
+ }
3986
+ /**
3987
+ * Start recording browser interactions.
3988
+ *
3989
+ * Sets up CDP bindings and injects the recorder script into
3990
+ * the current page and all future navigations.
3991
+ */
3992
+ async start() {
3993
+ if (this.recording) {
3994
+ throw new Error("Recording already in progress");
3995
+ }
3996
+ this.events = [];
3997
+ this.startTime = Date.now();
3998
+ this.recording = true;
3999
+ await this.cdp.send("Runtime.enable");
4000
+ await this.cdp.send("Page.enable");
4001
+ try {
4002
+ const result = await this.cdp.send("Runtime.evaluate", {
4003
+ expression: "location.href",
4004
+ returnByValue: true
4005
+ });
4006
+ this.startUrl = result.result.value;
4007
+ } catch {
4008
+ this.startUrl = "";
4009
+ }
4010
+ await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
4011
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
4012
+ source: RECORDER_SCRIPT
4013
+ });
4014
+ await this.cdp.send("Runtime.evaluate", {
4015
+ expression: RECORDER_SCRIPT,
4016
+ awaitPromise: false
4017
+ });
4018
+ this.bindingHandler = (params) => {
4019
+ if (params["name"] === RECORDER_BINDING_NAME) {
4020
+ this.handleBindingCall(params["payload"]);
4021
+ }
4022
+ };
4023
+ this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
4024
+ }
4025
+ /**
4026
+ * Stop recording and return aggregated output.
4027
+ *
4028
+ * Returns a RecordingOutput with steps compatible with page.batch().
4029
+ */
4030
+ async stop() {
4031
+ if (!this.recording) {
4032
+ throw new Error("No recording in progress");
4033
+ }
4034
+ this.recording = false;
4035
+ const duration = Date.now() - this.startTime;
4036
+ if (this.bindingHandler) {
4037
+ this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
4038
+ this.bindingHandler = null;
4039
+ }
4040
+ const steps = aggregateEvents(this.events, this.startUrl);
4041
+ return {
4042
+ recordedAt: new Date(this.startTime).toISOString(),
4043
+ startUrl: this.startUrl,
4044
+ duration,
4045
+ steps
4046
+ };
4047
+ }
4048
+ /**
4049
+ * Get raw recorded events (for debugging).
4050
+ */
4051
+ getEvents() {
4052
+ return [...this.events];
4053
+ }
4054
+ /**
4055
+ * Handle incoming binding call from the browser.
4056
+ */
4057
+ handleBindingCall(payload) {
4058
+ if (!this.recording) return;
4059
+ try {
4060
+ const event = JSON.parse(payload);
4061
+ this.events.push(event);
4062
+ } catch {
4063
+ }
4064
+ }
4065
+ };
4066
+
4067
+ // src/cli/commands/record.ts
4068
+ var RECORD_HELP = `
4069
+ bp record - Record browser actions to JSON
4070
+
4071
+ Usage:
4072
+ bp record [options]
4073
+
4074
+ Options:
4075
+ -s, --session [id] Session to use:
4076
+ - omit -s: auto-connect to local browser
4077
+ - -s alone: use most recent session
4078
+ - -s <id>: use specific session
4079
+ -f, --file <path> Output file (default: recording.json)
4080
+ --timeout <ms> Auto-stop after timeout (optional)
4081
+ -h, --help Show this help
4082
+
4083
+ Examples:
4084
+ bp record # Auto-connect to local Chrome
4085
+ bp record -s # Use most recent session
4086
+ bp record -s mysession # Use specific session
4087
+ bp record -f login.json # Save to specific file
4088
+ bp record --timeout 60000 # Auto-stop after 60s
4089
+
4090
+ Recording captures: clicks, inputs, form submissions, navigation.
4091
+ Password fields are automatically redacted as [REDACTED].
4092
+
4093
+ Press Ctrl+C to stop recording and save.
4094
+ `;
4095
+ function parseRecordArgs(args) {
4096
+ const options = {};
4097
+ for (let i = 0; i < args.length; i++) {
4098
+ const arg = args[i];
4099
+ if (arg === "-f" || arg === "--file") {
4100
+ options.file = args[++i];
4101
+ } else if (arg === "--timeout") {
4102
+ options.timeout = Number.parseInt(args[++i] ?? "", 10);
4103
+ } else if (arg === "-h" || arg === "--help") {
4104
+ options.help = true;
4105
+ } else if (arg === "-s" || arg === "--session") {
4106
+ const nextArg = args[i + 1];
4107
+ if (!nextArg || nextArg.startsWith("-")) {
4108
+ options.useLatestSession = true;
4109
+ }
4110
+ }
4111
+ }
4112
+ return options;
4113
+ }
4114
+ async function resolveConnection(sessionId, useLatestSession, trace) {
4115
+ if (sessionId) {
4116
+ const session2 = await loadSession(sessionId);
4117
+ const browser2 = await connect({
4118
+ provider: session2.provider,
4119
+ wsUrl: session2.wsUrl,
4120
+ debug: trace
4121
+ });
4122
+ return { browser: browser2, session: session2, isNewSession: false };
4123
+ }
4124
+ if (useLatestSession) {
4125
+ const session2 = await getDefaultSession();
4126
+ if (!session2) {
4127
+ throw new Error(
4128
+ 'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
4129
+ );
4130
+ }
4131
+ const browser2 = await connect({
4132
+ provider: session2.provider,
4133
+ wsUrl: session2.wsUrl,
4134
+ debug: trace
4135
+ });
4136
+ return { browser: browser2, session: session2, isNewSession: false };
4137
+ }
4138
+ let wsUrl;
4139
+ try {
4140
+ wsUrl = await getBrowserWebSocketUrl("localhost:9222");
4141
+ } catch {
4142
+ throw new Error(
4143
+ "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"
4144
+ );
4145
+ }
4146
+ const browser = await connect({
4147
+ provider: "generic",
4148
+ wsUrl,
4149
+ debug: trace
4150
+ });
4151
+ const page = await browser.page();
4152
+ const currentUrl = await page.url();
4153
+ const newSessionId = generateSessionId();
4154
+ const session = {
4155
+ id: newSessionId,
4156
+ provider: "generic",
4157
+ wsUrl: browser.wsUrl,
4158
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4159
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
4160
+ currentUrl
4161
+ };
4162
+ await saveSession(session);
4163
+ return { browser, session, isNewSession: true };
4164
+ }
4165
+ async function recordCommand(args, globalOptions) {
4166
+ const options = parseRecordArgs(args);
4167
+ if (options.help || globalOptions.help) {
4168
+ console.log(RECORD_HELP);
4169
+ return;
4170
+ }
4171
+ const outputFile = options.file ?? "recording.json";
4172
+ const { browser, session, isNewSession } = await resolveConnection(
4173
+ globalOptions.session,
4174
+ options.useLatestSession ?? false,
4175
+ globalOptions.trace ?? false
4176
+ );
4177
+ if (isNewSession) {
4178
+ console.log(`Created new session: ${session.id}`);
4179
+ }
4180
+ const page = await browser.page();
4181
+ const cdp = page.cdpClient;
4182
+ const recorder = new Recorder(cdp);
4183
+ let stopping = false;
4184
+ async function stopAndSave() {
4185
+ if (stopping) return;
4186
+ stopping = true;
4187
+ try {
4188
+ const recording = await recorder.stop();
4189
+ const fs = await import("fs/promises");
4190
+ await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
4191
+ const currentUrl = await page.url();
4192
+ await updateSession(session.id, { currentUrl });
4193
+ await browser.disconnect();
4194
+ console.log(`
4195
+ Saved ${recording.steps.length} steps to ${outputFile}`);
4196
+ if (globalOptions.output === "json") {
4197
+ output(
4198
+ {
4199
+ success: true,
4200
+ file: outputFile,
4201
+ steps: recording.steps.length,
4202
+ duration: recording.duration
4203
+ },
4204
+ "json"
4205
+ );
4206
+ }
4207
+ process.exit(0);
4208
+ } catch (error) {
4209
+ console.error("Error saving recording:", error);
4210
+ process.exit(1);
4211
+ }
4212
+ }
4213
+ process.on("SIGINT", stopAndSave);
4214
+ process.on("SIGTERM", stopAndSave);
4215
+ if (options.timeout && options.timeout > 0) {
4216
+ setTimeout(stopAndSave, options.timeout);
4217
+ }
4218
+ await recorder.start();
4219
+ console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
4220
+ console.log(`Session: ${session.id}`);
4221
+ console.log(`URL: ${await page.url()}`);
4222
+ }
4223
+
3381
4224
  // src/cli/commands/screenshot.ts
3382
4225
  function parseScreenshotArgs(args) {
3383
4226
  const options = {};
@@ -3551,11 +4394,13 @@ Commands:
3551
4394
  quickstart Getting started guide (start here!)
3552
4395
  connect Create browser session
3553
4396
  exec Execute actions
4397
+ record Record browser actions to JSON
3554
4398
  snapshot Get page with element refs
3555
4399
  text Extract text content
3556
4400
  screenshot Take screenshot
3557
4401
  close Close session
3558
4402
  list List sessions
4403
+ clean Clean up old sessions
3559
4404
  actions Complete action reference
3560
4405
 
3561
4406
  Options:
@@ -3570,6 +4415,8 @@ Examples:
3570
4415
  bp exec '{"action":"goto","url":"https://example.com"}'
3571
4416
  bp snapshot --format text
3572
4417
  bp exec '{"action":"click","selector":"ref:e3"}'
4418
+ bp record # Record from local browser
4419
+ bp record -s -f login.json # Record from latest session
3573
4420
 
3574
4421
  Run 'bp quickstart' for CLI workflow guide.
3575
4422
  Run 'bp actions' for complete action reference.
@@ -3602,7 +4449,10 @@ function output(data, format = "pretty") {
3602
4449
  if (typeof data === "string") {
3603
4450
  console.log(data);
3604
4451
  } else if (typeof data === "object" && data !== null) {
3605
- prettyPrint(data);
4452
+ const { truncated } = prettyPrint(data);
4453
+ if (truncated) {
4454
+ console.log("\n(Output truncated. Use -o json for full data)");
4455
+ }
3606
4456
  } else {
3607
4457
  console.log(data);
3608
4458
  }
@@ -3610,16 +4460,20 @@ function output(data, format = "pretty") {
3610
4460
  }
3611
4461
  function prettyPrint(obj, indent = 0) {
3612
4462
  const prefix = " ".repeat(indent);
4463
+ let truncated = false;
3613
4464
  for (const [key, value] of Object.entries(obj)) {
3614
4465
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
3615
4466
  console.log(`${prefix}${key}:`);
3616
- prettyPrint(value, indent + 1);
4467
+ const result = prettyPrint(value, indent + 1);
4468
+ if (result.truncated) truncated = true;
3617
4469
  } else if (Array.isArray(value)) {
3618
4470
  console.log(`${prefix}${key}: [${value.length} items]`);
4471
+ truncated = true;
3619
4472
  } else {
3620
4473
  console.log(`${prefix}${key}: ${value}`);
3621
4474
  }
3622
4475
  }
4476
+ return { truncated };
3623
4477
  }
3624
4478
  async function main() {
3625
4479
  const args = process.argv.slice(2);
@@ -3659,9 +4513,15 @@ async function main() {
3659
4513
  case "list":
3660
4514
  await listCommand(remaining, options);
3661
4515
  break;
4516
+ case "clean":
4517
+ await cleanCommand(remaining, options);
4518
+ break;
3662
4519
  case "actions":
3663
4520
  await actionsCommand();
3664
4521
  break;
4522
+ case "record":
4523
+ await recordCommand(remaining, options);
4524
+ break;
3665
4525
  case "help":
3666
4526
  case "--help":
3667
4527
  case "-h":