browser-pilot 0.0.7 → 0.0.8

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
@@ -209,6 +209,18 @@ EXAMPLES
209
209
  {"action":"click","selector":"#delete-btn"},
210
210
  {"action":"wait","selector":"#success-message","waitFor":"visible"}
211
211
  ]'
212
+
213
+ DEBUGGING
214
+ When actions fail, use these diagnostic tools:
215
+
216
+ bp diagnose '#selector' # Why can't this be found?
217
+ bp diagnose '#selector' --json # Machine-readable output
218
+ bp snapshot --diff prev.json # What changed on the page?
219
+ bp snapshot --inspect # Visual ref labels on page
220
+ bp list -s <id> --log-tail 10 # Recent command history
221
+
222
+ Failure hints are included in error output when element not found.
223
+ Use --json output for detailed hints with alternative selectors.
212
224
  `;
213
225
  async function actionsCommand() {
214
226
  console.log(ACTIONS_HELP);
@@ -219,20 +231,20 @@ var import_node_os = require("os");
219
231
  var import_node_path = require("path");
220
232
  var SESSION_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".browser-pilot", "sessions");
221
233
  async function ensureSessionDir() {
222
- const fs = await import("fs/promises");
223
- await fs.mkdir(SESSION_DIR, { recursive: true });
234
+ const fs3 = await import("fs/promises");
235
+ await fs3.mkdir(SESSION_DIR, { recursive: true });
224
236
  }
225
237
  async function saveSession(session) {
226
238
  await ensureSessionDir();
227
- const fs = await import("fs/promises");
239
+ const fs3 = await import("fs/promises");
228
240
  const filePath = (0, import_node_path.join)(SESSION_DIR, `${session.id}.json`);
229
- await fs.writeFile(filePath, JSON.stringify(session, null, 2));
241
+ await fs3.writeFile(filePath, JSON.stringify(session, null, 2));
230
242
  }
231
243
  async function loadSession(id) {
232
- const fs = await import("fs/promises");
244
+ const fs3 = await import("fs/promises");
233
245
  const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
234
246
  try {
235
- const content = await fs.readFile(filePath, "utf-8");
247
+ const content = await fs3.readFile(filePath, "utf-8");
236
248
  return JSON.parse(content);
237
249
  } catch (error) {
238
250
  if (error.code === "ENOENT") {
@@ -254,10 +266,10 @@ async function updateSession(id, updates) {
254
266
  return updated;
255
267
  }
256
268
  async function deleteSession(id) {
257
- const fs = await import("fs/promises");
269
+ const fs3 = await import("fs/promises");
258
270
  const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
259
271
  try {
260
- await fs.unlink(filePath);
272
+ await fs3.unlink(filePath);
261
273
  } catch (error) {
262
274
  if (error.code !== "ENOENT") {
263
275
  throw error;
@@ -266,14 +278,14 @@ async function deleteSession(id) {
266
278
  }
267
279
  async function listSessions() {
268
280
  await ensureSessionDir();
269
- const fs = await import("fs/promises");
281
+ const fs3 = await import("fs/promises");
270
282
  try {
271
- const files = await fs.readdir(SESSION_DIR);
283
+ const files = await fs3.readdir(SESSION_DIR);
272
284
  const sessions = [];
273
285
  for (const file of files) {
274
286
  if (file.endsWith(".json")) {
275
287
  try {
276
- const content = await fs.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
288
+ const content = await fs3.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
277
289
  sessions.push(JSON.parse(content));
278
290
  } catch {
279
291
  }
@@ -350,6 +362,25 @@ async function cleanCommand(args, globalOptions) {
350
362
  );
351
363
  }
352
364
 
365
+ // src/browser/types.ts
366
+ var ElementNotFoundError = class extends Error {
367
+ selectors;
368
+ hints;
369
+ constructor(selectors, hints) {
370
+ const selectorList = Array.isArray(selectors) ? selectors : [selectors];
371
+ super(`Element not found: ${selectorList.join(", ")}`);
372
+ this.name = "ElementNotFoundError";
373
+ this.selectors = selectorList;
374
+ this.hints = hints;
375
+ }
376
+ };
377
+ var TimeoutError = class extends Error {
378
+ constructor(message = "Operation timed out") {
379
+ super(message);
380
+ this.name = "TimeoutError";
381
+ }
382
+ };
383
+
353
384
  // src/actions/executor.ts
354
385
  var DEFAULT_TIMEOUT = 3e4;
355
386
  var BatchExecutor = class {
@@ -381,13 +412,15 @@ var BatchExecutor = class {
381
412
  });
382
413
  } catch (error) {
383
414
  const errorMessage = error instanceof Error ? error.message : String(error);
415
+ const hints = error instanceof ElementNotFoundError ? error.hints : void 0;
384
416
  results.push({
385
417
  index: i,
386
418
  action: step.action,
387
419
  selector: step.selector,
388
420
  success: false,
389
421
  durationMs: Date.now() - stepStart,
390
- error: errorMessage
422
+ error: errorMessage,
423
+ hints
391
424
  });
392
425
  if (onFail === "stop" && !step.optional) {
393
426
  return {
@@ -522,7 +555,7 @@ var BatchExecutor = class {
522
555
  case "wait": {
523
556
  if (!step.selector && !step.waitFor) {
524
557
  const delay = step.timeout ?? 1e3;
525
- await new Promise((resolve) => setTimeout(resolve, delay));
558
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
526
559
  return {};
527
560
  }
528
561
  if (step.waitFor === "navigation") {
@@ -583,10 +616,12 @@ var BatchExecutor = class {
583
616
  }
584
617
  }
585
618
  /**
586
- * Get the first selector if multiple were provided
587
- * (actual used selector tracking would need to be implemented in Page)
619
+ * Get the actual selector that matched the element.
620
+ * Uses the last matched selector tracked by Page, falls back to first selector if unavailable.
588
621
  */
589
622
  getUsedSelector(selector) {
623
+ const matched = this.page.getLastMatchedSelector();
624
+ if (matched) return matched;
590
625
  return Array.isArray(selector) ? selector[0] : selector;
591
626
  }
592
627
  };
@@ -612,7 +647,7 @@ var CDPError = class extends Error {
612
647
  // src/cdp/transport.ts
613
648
  function createTransport(wsUrl, options = {}) {
614
649
  const { timeout = 3e4 } = options;
615
- return new Promise((resolve, reject) => {
650
+ return new Promise((resolve2, reject) => {
616
651
  const timeoutId = setTimeout(() => {
617
652
  reject(new Error(`WebSocket connection timeout after ${timeout}ms`));
618
653
  }, timeout);
@@ -657,7 +692,7 @@ function createTransport(wsUrl, options = {}) {
657
692
  errorHandlers.push(handler);
658
693
  }
659
694
  };
660
- resolve(transport);
695
+ resolve2(transport);
661
696
  });
662
697
  ws.addEventListener("message", (event) => {
663
698
  const data = typeof event.data === "string" ? event.data : String(event.data);
@@ -781,13 +816,13 @@ async function createCDPClient(wsUrl, options = {}) {
781
816
  if (debug) {
782
817
  console.log("[CDP] -->", message.slice(0, 500));
783
818
  }
784
- return new Promise((resolve, reject) => {
819
+ return new Promise((resolve2, reject) => {
785
820
  const timer = setTimeout(() => {
786
821
  pending.delete(id);
787
822
  reject(new Error(`CDP command ${method} timed out after ${timeout}ms`));
788
823
  }, timeout);
789
824
  pending.set(id, {
790
- resolve,
825
+ resolve: resolve2,
791
826
  reject,
792
827
  method,
793
828
  timer
@@ -1285,7 +1320,7 @@ async function isElementAttached(cdp, selector, contextId) {
1285
1320
  return result.result.value === true;
1286
1321
  }
1287
1322
  function sleep(ms) {
1288
- return new Promise((resolve) => setTimeout(resolve, ms));
1323
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1289
1324
  }
1290
1325
  async function waitForAnyElement(cdp, selectors, options = {}) {
1291
1326
  const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
@@ -1332,14 +1367,14 @@ async function waitForNavigation(cdp, options = {}) {
1332
1367
  } catch {
1333
1368
  startUrl = "";
1334
1369
  }
1335
- return new Promise((resolve) => {
1370
+ return new Promise((resolve2) => {
1336
1371
  let resolved = false;
1337
1372
  const cleanup = [];
1338
1373
  const done = (success) => {
1339
1374
  if (resolved) return;
1340
1375
  resolved = true;
1341
1376
  for (const fn of cleanup) fn();
1342
- resolve({ success, waitedMs: Date.now() - startTime });
1377
+ resolve2({ success, waitedMs: Date.now() - startTime });
1343
1378
  };
1344
1379
  const timer = setTimeout(() => done(false), timeout);
1345
1380
  cleanup.push(() => clearTimeout(timer));
@@ -1380,19 +1415,19 @@ async function waitForNetworkIdle(cdp, options = {}) {
1380
1415
  const { timeout = 3e4, idleTime = 500 } = options;
1381
1416
  const startTime = Date.now();
1382
1417
  await cdp.send("Network.enable");
1383
- return new Promise((resolve) => {
1418
+ return new Promise((resolve2) => {
1384
1419
  let inFlight = 0;
1385
1420
  let idleTimer = null;
1386
1421
  const timeoutTimer = setTimeout(() => {
1387
1422
  cleanup();
1388
- resolve({ success: false, waitedMs: Date.now() - startTime });
1423
+ resolve2({ success: false, waitedMs: Date.now() - startTime });
1389
1424
  }, timeout);
1390
1425
  const checkIdle = () => {
1391
1426
  if (inFlight === 0) {
1392
1427
  if (idleTimer) clearTimeout(idleTimer);
1393
1428
  idleTimer = setTimeout(() => {
1394
1429
  cleanup();
1395
- resolve({ success: true, waitedMs: Date.now() - startTime });
1430
+ resolve2({ success: true, waitedMs: Date.now() - startTime });
1396
1431
  }, idleTime);
1397
1432
  }
1398
1433
  };
@@ -1421,27 +1456,256 @@ async function waitForNetworkIdle(cdp, options = {}) {
1421
1456
  });
1422
1457
  }
1423
1458
 
1424
- // src/browser/types.ts
1425
- var ElementNotFoundError = class extends Error {
1426
- selectors;
1427
- constructor(selectors) {
1428
- const selectorList = Array.isArray(selectors) ? selectors : [selectors];
1429
- super(`Element not found: ${selectorList.join(", ")}`);
1430
- this.name = "ElementNotFoundError";
1431
- this.selectors = selectorList;
1459
+ // src/browser/fuzzy-match.ts
1460
+ function jaroWinkler(a, b) {
1461
+ if (a.length === 0 && b.length === 0) return 0;
1462
+ if (a.length === 0 || b.length === 0) return 0;
1463
+ if (a === b) return 1;
1464
+ const s1 = a.toLowerCase();
1465
+ const s2 = b.toLowerCase();
1466
+ const matchWindow = Math.max(0, Math.floor(Math.max(s1.length, s2.length) / 2) - 1);
1467
+ const s1Matches = new Array(s1.length).fill(false);
1468
+ const s2Matches = new Array(s2.length).fill(false);
1469
+ let matches = 0;
1470
+ let transpositions = 0;
1471
+ for (let i = 0; i < s1.length; i++) {
1472
+ const start = Math.max(0, i - matchWindow);
1473
+ const end = Math.min(i + matchWindow + 1, s2.length);
1474
+ for (let j = start; j < end; j++) {
1475
+ if (s2Matches[j] || s1[i] !== s2[j]) continue;
1476
+ s1Matches[i] = true;
1477
+ s2Matches[j] = true;
1478
+ matches++;
1479
+ break;
1480
+ }
1481
+ }
1482
+ if (matches === 0) return 0;
1483
+ let k = 0;
1484
+ for (let i = 0; i < s1.length; i++) {
1485
+ if (!s1Matches[i]) continue;
1486
+ while (!s2Matches[k]) k++;
1487
+ if (s1[i] !== s2[k]) transpositions++;
1488
+ k++;
1489
+ }
1490
+ const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
1491
+ let prefix = 0;
1492
+ for (let i = 0; i < Math.min(4, Math.min(s1.length, s2.length)); i++) {
1493
+ if (s1[i] === s2[i]) {
1494
+ prefix++;
1495
+ } else {
1496
+ break;
1497
+ }
1432
1498
  }
1433
- };
1434
- var TimeoutError = class extends Error {
1435
- constructor(message = "Operation timed out") {
1436
- super(message);
1437
- this.name = "TimeoutError";
1499
+ const WINKLER_SCALING = 0.1;
1500
+ return jaro + prefix * WINKLER_SCALING * (1 - jaro);
1501
+ }
1502
+ function stringSimilarity(a, b) {
1503
+ if (a.length === 0 || b.length === 0) return 0;
1504
+ const lowerA = a.toLowerCase();
1505
+ const lowerB = b.toLowerCase();
1506
+ if (lowerA === lowerB) return 1;
1507
+ const jw = jaroWinkler(a, b);
1508
+ let containsBonus = 0;
1509
+ if (lowerB.includes(lowerA)) {
1510
+ containsBonus = 0.2;
1511
+ } else if (lowerA.includes(lowerB)) {
1512
+ containsBonus = 0.1;
1513
+ }
1514
+ return Math.min(1, jw + containsBonus);
1515
+ }
1516
+ function scoreElement(query, element) {
1517
+ const lowerQuery = query.toLowerCase();
1518
+ const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
1519
+ let nameScore = 0;
1520
+ if (element.name) {
1521
+ const lowerName = element.name.toLowerCase();
1522
+ if (lowerName === lowerQuery) {
1523
+ nameScore = 1;
1524
+ } else if (lowerName.includes(lowerQuery)) {
1525
+ nameScore = 0.8;
1526
+ } else if (words.length > 0) {
1527
+ const matchedWords = words.filter((w) => lowerName.includes(w));
1528
+ nameScore = matchedWords.length / words.length * 0.7;
1529
+ } else {
1530
+ nameScore = stringSimilarity(query, element.name) * 0.6;
1531
+ }
1532
+ }
1533
+ let roleScore = 0;
1534
+ const lowerRole = element.role.toLowerCase();
1535
+ if (lowerRole === lowerQuery || lowerQuery.includes(lowerRole)) {
1536
+ roleScore = 0.3;
1537
+ } else if (words.some((w) => lowerRole.includes(w))) {
1538
+ roleScore = 0.2;
1438
1539
  }
1540
+ let selectorScore = 0;
1541
+ const lowerSelector = element.selector.toLowerCase();
1542
+ if (words.some((w) => lowerSelector.includes(w))) {
1543
+ selectorScore = 0.2;
1544
+ }
1545
+ const totalScore = nameScore * 0.6 + roleScore * 0.25 + selectorScore * 0.15;
1546
+ return totalScore;
1547
+ }
1548
+ function explainMatch(query, element, score) {
1549
+ const reasons = [];
1550
+ const lowerQuery = query.toLowerCase();
1551
+ const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
1552
+ if (element.name) {
1553
+ const lowerName = element.name.toLowerCase();
1554
+ if (lowerName === lowerQuery) {
1555
+ reasons.push("exact name match");
1556
+ } else if (lowerName.includes(lowerQuery)) {
1557
+ reasons.push("name contains query");
1558
+ } else if (words.some((w) => lowerName.includes(w))) {
1559
+ const matchedWords = words.filter((w) => lowerName.includes(w));
1560
+ reasons.push(`name contains: ${matchedWords.join(", ")}`);
1561
+ } else if (stringSimilarity(query, element.name) > 0.5) {
1562
+ reasons.push("similar name");
1563
+ }
1564
+ }
1565
+ const lowerRole = element.role.toLowerCase();
1566
+ if (lowerRole === lowerQuery || words.some((w) => w === lowerRole)) {
1567
+ reasons.push(`role: ${element.role}`);
1568
+ }
1569
+ if (words.some((w) => element.selector.toLowerCase().includes(w))) {
1570
+ reasons.push("selector match");
1571
+ }
1572
+ if (reasons.length === 0) {
1573
+ reasons.push(`fuzzy match (score: ${score.toFixed(2)})`);
1574
+ }
1575
+ return reasons.join(", ");
1576
+ }
1577
+ function fuzzyMatchElements(query, elements, maxResults = 5) {
1578
+ if (!query || query.length === 0) {
1579
+ return [];
1580
+ }
1581
+ const THRESHOLD = 0.3;
1582
+ const scored = elements.map((element) => ({
1583
+ element,
1584
+ score: scoreElement(query, element)
1585
+ }));
1586
+ return scored.filter((s) => s.score >= THRESHOLD).sort((a, b) => b.score - a.score).slice(0, maxResults).map((s) => ({
1587
+ element: s.element,
1588
+ score: s.score,
1589
+ matchReason: explainMatch(query, s.element, s.score)
1590
+ }));
1591
+ }
1592
+
1593
+ // src/browser/hint-generator.ts
1594
+ var ACTION_ROLE_MAP = {
1595
+ click: ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"],
1596
+ fill: ["textbox", "searchbox", "textarea"],
1597
+ type: ["textbox", "searchbox", "textarea"],
1598
+ submit: ["button", "form"],
1599
+ select: ["combobox", "listbox", "option"],
1600
+ check: ["checkbox", "radio", "switch"],
1601
+ uncheck: ["checkbox", "switch"],
1602
+ focus: [],
1603
+ // Any focusable element
1604
+ hover: [],
1605
+ // Any element
1606
+ clear: ["textbox", "searchbox", "textarea"]
1439
1607
  };
1608
+ function extractIntent(selectors) {
1609
+ const patterns = [];
1610
+ let text = "";
1611
+ for (const selector of selectors) {
1612
+ if (selector.startsWith("ref:")) {
1613
+ continue;
1614
+ }
1615
+ const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
1616
+ if (idMatch) {
1617
+ patterns.push(idMatch[1]);
1618
+ }
1619
+ const ariaMatch = selector.match(/\[aria-label=["']([^"']+)["']\]/);
1620
+ if (ariaMatch) {
1621
+ patterns.push(ariaMatch[1]);
1622
+ }
1623
+ const testidMatch = selector.match(/\[data-testid=["']([^"']+)["']\]/);
1624
+ if (testidMatch) {
1625
+ patterns.push(testidMatch[1]);
1626
+ }
1627
+ const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
1628
+ if (classMatch) {
1629
+ patterns.push(classMatch[1]);
1630
+ }
1631
+ }
1632
+ patterns.sort((a, b) => b.length - a.length);
1633
+ text = patterns[0] ?? selectors[0] ?? "";
1634
+ return { text, patterns };
1635
+ }
1636
+ function getHintType(selector) {
1637
+ if (selector.startsWith("ref:")) return "ref";
1638
+ if (selector.includes("data-testid")) return "testid";
1639
+ if (selector.includes("aria-label")) return "aria";
1640
+ if (selector.startsWith("#")) return "id";
1641
+ return "css";
1642
+ }
1643
+ function getConfidence(score) {
1644
+ if (score >= 0.8) return "high";
1645
+ if (score >= 0.5) return "medium";
1646
+ return "low";
1647
+ }
1648
+ function diversifyHints(candidates, maxHints) {
1649
+ const hints = [];
1650
+ const usedTypes = /* @__PURE__ */ new Set();
1651
+ for (const candidate of candidates) {
1652
+ if (hints.length >= maxHints) break;
1653
+ const refSelector = `ref:${candidate.element.ref}`;
1654
+ const hintType = getHintType(refSelector);
1655
+ if (!usedTypes.has(hintType)) {
1656
+ hints.push({
1657
+ selector: refSelector,
1658
+ reason: candidate.matchReason,
1659
+ confidence: getConfidence(candidate.score),
1660
+ element: {
1661
+ ref: candidate.element.ref,
1662
+ role: candidate.element.role,
1663
+ name: candidate.element.name,
1664
+ disabled: candidate.element.disabled
1665
+ }
1666
+ });
1667
+ usedTypes.add(hintType);
1668
+ } else if (hints.length < maxHints) {
1669
+ hints.push({
1670
+ selector: refSelector,
1671
+ reason: candidate.matchReason,
1672
+ confidence: getConfidence(candidate.score),
1673
+ element: {
1674
+ ref: candidate.element.ref,
1675
+ role: candidate.element.role,
1676
+ name: candidate.element.name,
1677
+ disabled: candidate.element.disabled
1678
+ }
1679
+ });
1680
+ }
1681
+ }
1682
+ return hints;
1683
+ }
1684
+ async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
1685
+ let snapshot;
1686
+ try {
1687
+ snapshot = await page.snapshot();
1688
+ } catch {
1689
+ return [];
1690
+ }
1691
+ const intent = extractIntent(failedSelectors);
1692
+ const roleFilter = ACTION_ROLE_MAP[actionType] ?? [];
1693
+ let candidates = snapshot.interactiveElements;
1694
+ if (roleFilter.length > 0) {
1695
+ candidates = candidates.filter((el) => roleFilter.includes(el.role));
1696
+ }
1697
+ const matches = fuzzyMatchElements(intent.text, candidates, maxHints * 2);
1698
+ if (matches.length === 0) {
1699
+ return [];
1700
+ }
1701
+ return diversifyHints(matches, maxHints);
1702
+ }
1440
1703
 
1441
1704
  // src/browser/page.ts
1442
1705
  var DEFAULT_TIMEOUT2 = 3e4;
1443
1706
  var Page = class {
1444
1707
  cdp;
1708
+ _targetId;
1445
1709
  rootNodeId = null;
1446
1710
  batchExecutor;
1447
1711
  emulationState = {};
@@ -1460,10 +1724,19 @@ var Page = class {
1460
1724
  frameExecutionContexts = /* @__PURE__ */ new Map();
1461
1725
  /** Current frame's execution context ID (null = main frame default) */
1462
1726
  currentFrameContextId = null;
1463
- constructor(cdp) {
1727
+ /** Last matched selector from findElement (for selectorUsed tracking) */
1728
+ _lastMatchedSelector;
1729
+ constructor(cdp, targetId) {
1464
1730
  this.cdp = cdp;
1731
+ this._targetId = targetId;
1465
1732
  this.batchExecutor = new BatchExecutor(this);
1466
1733
  }
1734
+ /**
1735
+ * Get the CDP target ID for this page
1736
+ */
1737
+ get targetId() {
1738
+ return this._targetId;
1739
+ }
1467
1740
  /**
1468
1741
  * Get the underlying CDP client for advanced operations.
1469
1742
  * Use with caution - prefer high-level Page methods when possible.
@@ -1471,6 +1744,13 @@ var Page = class {
1471
1744
  get cdpClient() {
1472
1745
  return this.cdp;
1473
1746
  }
1747
+ /**
1748
+ * Get the last matched selector from findElement (for selectorUsed tracking).
1749
+ * Returns undefined if no selector has been matched yet.
1750
+ */
1751
+ getLastMatchedSelector() {
1752
+ return this._lastMatchedSelector;
1753
+ }
1474
1754
  /**
1475
1755
  * Initialize the page (enable required CDP domains)
1476
1756
  */
@@ -1590,7 +1870,9 @@ var Page = class {
1590
1870
  const element = await this.findElement(selector, options);
1591
1871
  if (!element) {
1592
1872
  if (options.optional) return false;
1593
- throw new ElementNotFoundError(selector);
1873
+ const selectorList = Array.isArray(selector) ? selector : [selector];
1874
+ const hints = await generateHints(this, selectorList, "click");
1875
+ throw new ElementNotFoundError(selector, hints);
1594
1876
  }
1595
1877
  await this.scrollIntoView(element.nodeId);
1596
1878
  const submitResult = await this.evaluateInFrame(
@@ -1626,7 +1908,9 @@ var Page = class {
1626
1908
  const element = await this.findElement(selector, options);
1627
1909
  if (!element) {
1628
1910
  if (options.optional) return false;
1629
- throw new ElementNotFoundError(selector);
1911
+ const selectorList = Array.isArray(selector) ? selector : [selector];
1912
+ const hints = await generateHints(this, selectorList, "fill");
1913
+ throw new ElementNotFoundError(selector, hints);
1630
1914
  }
1631
1915
  await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
1632
1916
  if (clear) {
@@ -1704,7 +1988,9 @@ var Page = class {
1704
1988
  const element = await this.findElement(selector, options);
1705
1989
  if (!element) {
1706
1990
  if (options.optional) return false;
1707
- throw new ElementNotFoundError(selector);
1991
+ const selectorList = Array.isArray(selector) ? selector : [selector];
1992
+ const hints = await generateHints(this, selectorList, "select");
1993
+ throw new ElementNotFoundError(selector, hints);
1708
1994
  }
1709
1995
  const values = Array.isArray(value) ? value : [value];
1710
1996
  await this.cdp.send("Runtime.evaluate", {
@@ -1765,7 +2051,9 @@ var Page = class {
1765
2051
  const element = await this.findElement(selector, options);
1766
2052
  if (!element) {
1767
2053
  if (options.optional) return false;
1768
- throw new ElementNotFoundError(selector);
2054
+ const selectorList = Array.isArray(selector) ? selector : [selector];
2055
+ const hints = await generateHints(this, selectorList, "check");
2056
+ throw new ElementNotFoundError(selector, hints);
1769
2057
  }
1770
2058
  const result = await this.cdp.send("Runtime.evaluate", {
1771
2059
  expression: `(() => {
@@ -1785,7 +2073,9 @@ var Page = class {
1785
2073
  const element = await this.findElement(selector, options);
1786
2074
  if (!element) {
1787
2075
  if (options.optional) return false;
1788
- throw new ElementNotFoundError(selector);
2076
+ const selectorList = Array.isArray(selector) ? selector : [selector];
2077
+ const hints = await generateHints(this, selectorList, "uncheck");
2078
+ throw new ElementNotFoundError(selector, hints);
1789
2079
  }
1790
2080
  const result = await this.cdp.send("Runtime.evaluate", {
1791
2081
  expression: `(() => {
@@ -1805,13 +2095,40 @@ var Page = class {
1805
2095
  * - 'auto' (default): Attempt to detect navigation for 1 second, then assume client-side handling
1806
2096
  * - true: Wait for full navigation (traditional forms)
1807
2097
  * - false: Return immediately (AJAX forms where you'll wait for something else)
2098
+ *
2099
+ * When targeting a <form> element directly, uses form.requestSubmit() which fires
2100
+ * the submit event and triggers HTML5 validation.
1808
2101
  */
1809
2102
  async submit(selector, options = {}) {
1810
2103
  const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
1811
2104
  const element = await this.findElement(selector, options);
1812
2105
  if (!element) {
1813
2106
  if (options.optional) return false;
1814
- throw new ElementNotFoundError(selector);
2107
+ const selectorList = Array.isArray(selector) ? selector : [selector];
2108
+ const hints = await generateHints(this, selectorList, "submit");
2109
+ throw new ElementNotFoundError(selector, hints);
2110
+ }
2111
+ const isFormElement = await this.evaluateInFrame(
2112
+ `(() => {
2113
+ const el = document.querySelector(${JSON.stringify(element.selector)});
2114
+ return el instanceof HTMLFormElement;
2115
+ })()`
2116
+ );
2117
+ if (isFormElement.result.value) {
2118
+ await this.evaluateInFrame(
2119
+ `(() => {
2120
+ const form = document.querySelector(${JSON.stringify(element.selector)});
2121
+ if (form && form instanceof HTMLFormElement) {
2122
+ form.requestSubmit();
2123
+ }
2124
+ })()`
2125
+ );
2126
+ if (shouldWait === true) {
2127
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
2128
+ } else if (shouldWait === "auto") {
2129
+ await Promise.race([this.waitForNavigation({ timeout: 1e3, optional: true }), sleep2(500)]);
2130
+ }
2131
+ return true;
1815
2132
  }
1816
2133
  await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
1817
2134
  if (method.includes("enter")) {
@@ -1882,7 +2199,9 @@ var Page = class {
1882
2199
  const element = await this.findElement(selector, options);
1883
2200
  if (!element) {
1884
2201
  if (options.optional) return false;
1885
- throw new ElementNotFoundError(selector);
2202
+ const selectorList = Array.isArray(selector) ? selector : [selector];
2203
+ const hints = await generateHints(this, selectorList, "focus");
2204
+ throw new ElementNotFoundError(selector, hints);
1886
2205
  }
1887
2206
  await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
1888
2207
  return true;
@@ -1895,7 +2214,9 @@ var Page = class {
1895
2214
  const element = await this.findElement(selector, options);
1896
2215
  if (!element) {
1897
2216
  if (options.optional) return false;
1898
- throw new ElementNotFoundError(selector);
2217
+ const selectorList = Array.isArray(selector) ? selector : [selector];
2218
+ const hints = await generateHints(this, selectorList, "hover");
2219
+ throw new ElementNotFoundError(selector, hints);
1899
2220
  }
1900
2221
  await this.scrollIntoView(element.nodeId);
1901
2222
  const box = await this.getBoxModel(element.nodeId);
@@ -1966,7 +2287,7 @@ var Page = class {
1966
2287
  const deadline = Date.now() + timeout;
1967
2288
  let contextId = this.frameExecutionContexts.get(frameId);
1968
2289
  while (!contextId && Date.now() < deadline) {
1969
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
2290
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
1970
2291
  contextId = this.frameExecutionContexts.get(frameId);
1971
2292
  }
1972
2293
  if (contextId) {
@@ -2145,7 +2466,7 @@ var Page = class {
2145
2466
  behavior: "allowAndName",
2146
2467
  eventsEnabled: true
2147
2468
  });
2148
- return new Promise((resolve, reject) => {
2469
+ return new Promise((resolve2, reject) => {
2149
2470
  let downloadGuid;
2150
2471
  let suggestedFilename;
2151
2472
  let resolved = false;
@@ -2169,7 +2490,7 @@ var Page = class {
2169
2490
  return new ArrayBuffer(0);
2170
2491
  }
2171
2492
  };
2172
- resolve(download);
2493
+ resolve2(download);
2173
2494
  } else if (params["guid"] === downloadGuid && params["state"] === "canceled") {
2174
2495
  resolved = true;
2175
2496
  cleanup();
@@ -2850,6 +3171,7 @@ var Page = class {
2850
3171
  async findElement(selectors, options = {}) {
2851
3172
  const { timeout = DEFAULT_TIMEOUT2 } = options;
2852
3173
  const selectorList = Array.isArray(selectors) ? selectors : [selectors];
3174
+ this._lastMatchedSelector = void 0;
2853
3175
  for (const selector of selectorList) {
2854
3176
  if (selector.startsWith("ref:")) {
2855
3177
  const ref = selector.slice(4);
@@ -2866,6 +3188,7 @@ var Page = class {
2866
3188
  }
2867
3189
  );
2868
3190
  if (pushResult.nodeIds?.[0]) {
3191
+ this._lastMatchedSelector = selector;
2869
3192
  return {
2870
3193
  nodeId: pushResult.nodeIds[0],
2871
3194
  backendNodeId,
@@ -2899,6 +3222,7 @@ var Page = class {
2899
3222
  "DOM.describeNode",
2900
3223
  { nodeId: queryResult.nodeId }
2901
3224
  );
3225
+ this._lastMatchedSelector = result.selector;
2902
3226
  return {
2903
3227
  nodeId: queryResult.nodeId,
2904
3228
  backendNodeId: describeResult2.node.backendNodeId,
@@ -2926,6 +3250,7 @@ var Page = class {
2926
3250
  "DOM.describeNode",
2927
3251
  { nodeId: nodeResult.nodeId }
2928
3252
  );
3253
+ this._lastMatchedSelector = result.selector;
2929
3254
  return {
2930
3255
  nodeId: nodeResult.nodeId,
2931
3256
  backendNodeId: describeResult.node.backendNodeId,
@@ -3004,7 +3329,7 @@ var Page = class {
3004
3329
  }
3005
3330
  };
3006
3331
  function sleep2(ms) {
3007
- return new Promise((resolve) => setTimeout(resolve, ms));
3332
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3008
3333
  }
3009
3334
 
3010
3335
  // src/browser/browser.ts
@@ -3032,14 +3357,24 @@ var Browser = class _Browser {
3032
3357
  * Get or create a page by name
3033
3358
  * If no name is provided, returns the first available page or creates a new one
3034
3359
  */
3035
- async page(name) {
3360
+ async page(name, options) {
3036
3361
  const pageName = name ?? "default";
3037
3362
  const cached = this.pages.get(pageName);
3038
3363
  if (cached) return cached;
3039
3364
  const targets = await this.cdp.send("Target.getTargets");
3040
3365
  const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
3041
3366
  let targetId;
3042
- if (pageTargets.length > 0) {
3367
+ if (options?.targetId) {
3368
+ const targetExists = pageTargets.some((t) => t.targetId === options.targetId);
3369
+ if (targetExists) {
3370
+ targetId = options.targetId;
3371
+ } else {
3372
+ console.warn(`[browser-pilot] Target ${options.targetId} no longer exists, falling back`);
3373
+ targetId = pageTargets.length > 0 ? pageTargets[0].targetId : (await this.cdp.send("Target.createTarget", {
3374
+ url: "about:blank"
3375
+ })).targetId;
3376
+ }
3377
+ } else if (pageTargets.length > 0) {
3043
3378
  targetId = pageTargets[0].targetId;
3044
3379
  } else {
3045
3380
  const result = await this.cdp.send("Target.createTarget", {
@@ -3048,7 +3383,7 @@ var Browser = class _Browser {
3048
3383
  targetId = result.targetId;
3049
3384
  }
3050
3385
  await this.cdp.attachToTarget(targetId);
3051
- const page = new Page(this.cdp);
3386
+ const page = new Page(this.cdp, targetId);
3052
3387
  await page.init();
3053
3388
  this.pages.set(pageName, page);
3054
3389
  return page;
@@ -3061,7 +3396,7 @@ var Browser = class _Browser {
3061
3396
  url
3062
3397
  });
3063
3398
  await this.cdp.attachToTarget(result.targetId);
3064
- const page = new Page(this.cdp);
3399
+ const page = new Page(this.cdp, result.targetId);
3065
3400
  await page.init();
3066
3401
  const name = `page-${this.pages.size + 1}`;
3067
3402
  this.pages.set(name, page);
@@ -3182,6 +3517,8 @@ function parseConnectArgs(args) {
3182
3517
  options.apiKey = args[++i];
3183
3518
  } else if (arg === "--project-id") {
3184
3519
  options.projectId = args[++i];
3520
+ } else if (arg === "--export-log") {
3521
+ options.exportLog = args[++i];
3185
3522
  }
3186
3523
  }
3187
3524
  return options;
@@ -3230,6 +3567,8 @@ async function connectCommand(args, globalOptions) {
3230
3567
  provider,
3231
3568
  wsUrl: browser.wsUrl,
3232
3569
  providerSessionId: browser.sessionId,
3570
+ targetId: page.targetId,
3571
+ exportLog: options.exportLog,
3233
3572
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3234
3573
  lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
3235
3574
  currentUrl,
@@ -3249,6 +3588,808 @@ async function connectCommand(args, globalOptions) {
3249
3588
  );
3250
3589
  }
3251
3590
 
3591
+ // src/browser/selector-generator.ts
3592
+ function escapeAttrValue(value) {
3593
+ return value.replace(/"/g, '\\"').replace(/'/g, "\\'");
3594
+ }
3595
+ function extractTestId(selector) {
3596
+ const patterns = [
3597
+ /\[data-testid=["']([^"']+)["']\]/,
3598
+ /\[data-test-id=["']([^"']+)["']\]/,
3599
+ /\[data-test=["']([^"']+)["']\]/
3600
+ ];
3601
+ for (const pattern of patterns) {
3602
+ const match = selector.match(pattern);
3603
+ if (match) {
3604
+ return match[1] ?? null;
3605
+ }
3606
+ }
3607
+ return null;
3608
+ }
3609
+ function extractId(selector) {
3610
+ const match = selector.match(/#([a-zA-Z][a-zA-Z0-9_-]*)/);
3611
+ return match ? match[1] ?? null : null;
3612
+ }
3613
+ function generateRoleSelector(element) {
3614
+ if (!element.role || !element.name) {
3615
+ return null;
3616
+ }
3617
+ const escapedName = escapeAttrValue(element.name);
3618
+ return `[role="${element.role}"][aria-label="${escapedName}"]`;
3619
+ }
3620
+ function simplifyCssPath(selector) {
3621
+ if (selector.startsWith("#") || selector.startsWith(".") || selector.startsWith("[")) {
3622
+ return selector;
3623
+ }
3624
+ const parts = selector.split(/\s+/);
3625
+ const lastPart = parts[parts.length - 1];
3626
+ if (lastPart && (lastPart.startsWith("#") || lastPart.includes("[") || lastPart.includes("."))) {
3627
+ return lastPart;
3628
+ }
3629
+ return selector;
3630
+ }
3631
+ function generateSelectors(element, _snapshot) {
3632
+ const selectors = [];
3633
+ if (element.ref) {
3634
+ selectors.push({
3635
+ selector: `ref:${element.ref}`,
3636
+ type: "ref"
3637
+ });
3638
+ }
3639
+ const testId = extractTestId(element.selector);
3640
+ if (testId) {
3641
+ selectors.push({
3642
+ selector: `[data-testid="${escapeAttrValue(testId)}"]`,
3643
+ type: "testid"
3644
+ });
3645
+ }
3646
+ if (element.name) {
3647
+ selectors.push({
3648
+ selector: `[aria-label="${escapeAttrValue(element.name)}"]`,
3649
+ type: "aria-label"
3650
+ });
3651
+ }
3652
+ const id = extractId(element.selector);
3653
+ if (id) {
3654
+ selectors.push({
3655
+ selector: `#${id}`,
3656
+ type: "id"
3657
+ });
3658
+ }
3659
+ const roleSelector = generateRoleSelector(element);
3660
+ if (roleSelector) {
3661
+ selectors.push({
3662
+ selector: roleSelector,
3663
+ type: "role-name"
3664
+ });
3665
+ }
3666
+ const cssPath = simplifyCssPath(element.selector);
3667
+ if (cssPath && !selectors.some((s) => s.selector === cssPath)) {
3668
+ selectors.push({
3669
+ selector: cssPath,
3670
+ type: "css"
3671
+ });
3672
+ }
3673
+ return selectors;
3674
+ }
3675
+ function generateSelectorStrings(element, snapshot) {
3676
+ return generateSelectors(element, snapshot).map((s) => s.selector);
3677
+ }
3678
+
3679
+ // src/browser/visibility.ts
3680
+ var VISIBILITY_STATE_SCRIPT = `
3681
+ function getVisibilityState(selector) {
3682
+ ${DEEP_QUERY_SCRIPT}
3683
+ const el = deepQuery(selector);
3684
+ if (!el) {
3685
+ return null;
3686
+ }
3687
+
3688
+ const style = getComputedStyle(el);
3689
+ const rect = el.getBoundingClientRect();
3690
+
3691
+ const display = style.display;
3692
+ const visibility = style.visibility;
3693
+ const opacity = parseFloat(style.opacity);
3694
+ const width = rect.width;
3695
+ const height = rect.height;
3696
+
3697
+ // Check if in viewport
3698
+ const inViewport = (
3699
+ rect.top < window.innerHeight &&
3700
+ rect.bottom > 0 &&
3701
+ rect.left < window.innerWidth &&
3702
+ rect.right > 0
3703
+ );
3704
+
3705
+ // Collect reasons for invisibility
3706
+ const reasons = [];
3707
+ if (display === 'none') {
3708
+ reasons.push('display: none');
3709
+ }
3710
+ if (visibility === 'hidden') {
3711
+ reasons.push('visibility: hidden');
3712
+ }
3713
+ if (opacity === 0) {
3714
+ reasons.push('opacity: 0');
3715
+ }
3716
+ if (width === 0 && height === 0) {
3717
+ reasons.push('zero dimensions');
3718
+ } else if (width === 0) {
3719
+ reasons.push('zero width');
3720
+ } else if (height === 0) {
3721
+ reasons.push('zero height');
3722
+ }
3723
+ if (!inViewport && width > 0 && height > 0) {
3724
+ reasons.push('outside viewport');
3725
+ }
3726
+
3727
+ const visible = reasons.length === 0;
3728
+
3729
+ return {
3730
+ visible,
3731
+ display,
3732
+ visibility,
3733
+ opacity,
3734
+ width,
3735
+ height,
3736
+ inViewport,
3737
+ reasons
3738
+ };
3739
+ }
3740
+ `;
3741
+ var COVERING_ELEMENT_SCRIPT = `
3742
+ function detectCoveringElement(selector) {
3743
+ ${DEEP_QUERY_SCRIPT}
3744
+ const el = deepQuery(selector);
3745
+ if (!el) {
3746
+ return { error: 'Element not found' };
3747
+ }
3748
+
3749
+ const rect = el.getBoundingClientRect();
3750
+
3751
+ // Check center point
3752
+ const centerX = rect.left + rect.width / 2;
3753
+ const centerY = rect.top + rect.height / 2;
3754
+
3755
+ // Get element at center point
3756
+ const topEl = document.elementFromPoint(centerX, centerY);
3757
+
3758
+ if (!topEl) {
3759
+ return { covered: false };
3760
+ }
3761
+
3762
+ // Check if the target element is the top element or contains it
3763
+ if (topEl === el || el.contains(topEl)) {
3764
+ return { covered: false };
3765
+ }
3766
+
3767
+ // Element is covered - get info about covering element
3768
+ const style = getComputedStyle(topEl);
3769
+ return {
3770
+ covered: true,
3771
+ coveringElement: {
3772
+ tagName: topEl.tagName.toLowerCase(),
3773
+ id: topEl.id || undefined,
3774
+ className: topEl.className || undefined,
3775
+ zIndex: style.zIndex === 'auto' ? undefined : parseInt(style.zIndex, 10)
3776
+ }
3777
+ };
3778
+ }
3779
+ `;
3780
+ var VISIBILITY_BY_NODE_SCRIPT = `
3781
+ function getVisibilityStateByNode(nodeId) {
3782
+ // This script expects nodeId to be resolved to an element via CDP
3783
+ // It's called after Runtime.callFunctionOn with the target element
3784
+ const el = this;
3785
+ if (!el) {
3786
+ return null;
3787
+ }
3788
+
3789
+ const style = getComputedStyle(el);
3790
+ const rect = el.getBoundingClientRect();
3791
+
3792
+ const display = style.display;
3793
+ const visibility = style.visibility;
3794
+ const opacity = parseFloat(style.opacity);
3795
+ const width = rect.width;
3796
+ const height = rect.height;
3797
+
3798
+ const inViewport = (
3799
+ rect.top < window.innerHeight &&
3800
+ rect.bottom > 0 &&
3801
+ rect.left < window.innerWidth &&
3802
+ rect.right > 0
3803
+ );
3804
+
3805
+ const reasons = [];
3806
+ if (display === 'none') reasons.push('display: none');
3807
+ if (visibility === 'hidden') reasons.push('visibility: hidden');
3808
+ if (opacity === 0) reasons.push('opacity: 0');
3809
+ if (width === 0 && height === 0) reasons.push('zero dimensions');
3810
+ else if (width === 0) reasons.push('zero width');
3811
+ else if (height === 0) reasons.push('zero height');
3812
+ if (!inViewport && width > 0 && height > 0) reasons.push('outside viewport');
3813
+
3814
+ return {
3815
+ visible: reasons.length === 0,
3816
+ display,
3817
+ visibility,
3818
+ opacity,
3819
+ width,
3820
+ height,
3821
+ inViewport,
3822
+ reasons
3823
+ };
3824
+ }
3825
+ `;
3826
+ var COVERING_BY_NODE_SCRIPT = `
3827
+ function detectCoveringByNode() {
3828
+ const el = this;
3829
+ if (!el) {
3830
+ return { error: 'Element not found' };
3831
+ }
3832
+
3833
+ const rect = el.getBoundingClientRect();
3834
+ const centerX = rect.left + rect.width / 2;
3835
+ const centerY = rect.top + rect.height / 2;
3836
+
3837
+ const topEl = document.elementFromPoint(centerX, centerY);
3838
+
3839
+ if (!topEl) {
3840
+ return { covered: false };
3841
+ }
3842
+
3843
+ if (topEl === el || el.contains(topEl)) {
3844
+ return { covered: false };
3845
+ }
3846
+
3847
+ const style = getComputedStyle(topEl);
3848
+ return {
3849
+ covered: true,
3850
+ coveringElement: {
3851
+ tagName: topEl.tagName.toLowerCase(),
3852
+ id: topEl.id || undefined,
3853
+ className: topEl.className || undefined,
3854
+ zIndex: style.zIndex === 'auto' ? undefined : parseInt(style.zIndex, 10)
3855
+ }
3856
+ };
3857
+ }
3858
+ `;
3859
+ async function getVisibilityState(cdp, nodeId) {
3860
+ const resolveResult = await cdp.send("DOM.resolveNode", {
3861
+ nodeId
3862
+ });
3863
+ if (!resolveResult.object?.objectId) {
3864
+ return null;
3865
+ }
3866
+ const objectId = resolveResult.object.objectId;
3867
+ const result = await cdp.send(
3868
+ "Runtime.callFunctionOn",
3869
+ {
3870
+ objectId,
3871
+ functionDeclaration: VISIBILITY_BY_NODE_SCRIPT,
3872
+ returnByValue: true
3873
+ }
3874
+ );
3875
+ return result.result.value;
3876
+ }
3877
+ async function detectCoveringElement(cdp, nodeId) {
3878
+ const resolveResult = await cdp.send("DOM.resolveNode", {
3879
+ nodeId
3880
+ });
3881
+ if (!resolveResult.object?.objectId) {
3882
+ return null;
3883
+ }
3884
+ const objectId = resolveResult.object.objectId;
3885
+ const result = await cdp.send("Runtime.callFunctionOn", {
3886
+ objectId,
3887
+ functionDeclaration: COVERING_BY_NODE_SCRIPT,
3888
+ returnByValue: true
3889
+ });
3890
+ const value = result.result.value;
3891
+ if (value.error || !value.covered) {
3892
+ return null;
3893
+ }
3894
+ return value.coveringElement ?? null;
3895
+ }
3896
+
3897
+ // src/browser/diagnose.ts
3898
+ function isFuzzyQuery(selector) {
3899
+ if (selector.startsWith("ref:")) return false;
3900
+ if (/^[#.[]/.test(selector)) return false;
3901
+ if (/\[.*\]/.test(selector)) return false;
3902
+ if (/\s/.test(selector) && !/\s*[>+~]\s*/.test(selector)) return true;
3903
+ if (!/[#.[\]>+~:=]/.test(selector)) return true;
3904
+ return false;
3905
+ }
3906
+ async function tryExactMatch(page, selector) {
3907
+ const cdp = page.cdpClient;
3908
+ if (selector.startsWith("ref:")) {
3909
+ const ref = selector.slice(4);
3910
+ const refMap = page.exportRefMap();
3911
+ const backendNodeId = refMap[ref];
3912
+ if (!backendNodeId) {
3913
+ return { found: false };
3914
+ }
3915
+ try {
3916
+ await cdp.send("DOM.getDocument");
3917
+ const pushResult = await cdp.send(
3918
+ "DOM.pushNodesByBackendIdsToFrontend",
3919
+ {
3920
+ backendNodeIds: [backendNodeId]
3921
+ }
3922
+ );
3923
+ if (pushResult.nodeIds?.[0]) {
3924
+ return {
3925
+ found: true,
3926
+ nodeId: pushResult.nodeIds[0],
3927
+ backendNodeId,
3928
+ selectorUsed: selector
3929
+ };
3930
+ }
3931
+ } catch {
3932
+ return { found: false };
3933
+ }
3934
+ }
3935
+ try {
3936
+ const doc = await cdp.send("DOM.getDocument");
3937
+ const rootNodeId = doc.root.nodeId;
3938
+ const result = await cdp.send("DOM.querySelector", {
3939
+ nodeId: rootNodeId,
3940
+ selector
3941
+ });
3942
+ if (result.nodeId && result.nodeId !== 0) {
3943
+ const describe = await cdp.send("DOM.describeNode", {
3944
+ nodeId: result.nodeId
3945
+ });
3946
+ return {
3947
+ found: true,
3948
+ nodeId: result.nodeId,
3949
+ backendNodeId: describe.node.backendNodeId,
3950
+ selectorUsed: selector
3951
+ };
3952
+ }
3953
+ } catch {
3954
+ }
3955
+ return { found: false };
3956
+ }
3957
+ async function getElementAttributes(page, nodeId) {
3958
+ const cdp = page.cdpClient;
3959
+ const attributes = {};
3960
+ try {
3961
+ const result = await cdp.send("DOM.getAttributes", {
3962
+ nodeId
3963
+ });
3964
+ for (let i = 0; i < result.attributes.length; i += 2) {
3965
+ const name = result.attributes[i];
3966
+ const value = result.attributes[i + 1];
3967
+ if (name && value !== void 0) {
3968
+ attributes[name] = value;
3969
+ }
3970
+ }
3971
+ } catch {
3972
+ }
3973
+ return attributes;
3974
+ }
3975
+ async function diagnoseElement(page, selector, options = {}) {
3976
+ const { maxCandidates = 5, includeHidden = false } = options;
3977
+ const cdp = page.cdpClient;
3978
+ const snapshot = await page.snapshot();
3979
+ const fuzzy = isFuzzyQuery(selector);
3980
+ if (!fuzzy) {
3981
+ const match = await tryExactMatch(page, selector);
3982
+ if (match.found && match.nodeId && match.backendNodeId) {
3983
+ const visibility = await getVisibilityState(cdp, match.nodeId) ?? {
3984
+ visible: false,
3985
+ display: "unknown",
3986
+ visibility: "unknown",
3987
+ opacity: 1,
3988
+ width: 0,
3989
+ height: 0,
3990
+ inViewport: false,
3991
+ reasons: ["Could not determine visibility"]
3992
+ };
3993
+ const covering = await detectCoveringElement(cdp, match.nodeId);
3994
+ const attributes = await getElementAttributes(page, match.nodeId);
3995
+ const refMap = page.exportRefMap();
3996
+ let ref = "";
3997
+ for (const [r, bid] of Object.entries(refMap)) {
3998
+ if (bid === match.backendNodeId) {
3999
+ ref = r;
4000
+ break;
4001
+ }
4002
+ }
4003
+ const interactiveEl = snapshot.interactiveElements.find((el) => el.ref === ref);
4004
+ const disabled = attributes["disabled"] !== void 0 || interactiveEl?.disabled === true;
4005
+ const readonly = attributes["readonly"] !== void 0;
4006
+ const covered = covering !== null;
4007
+ const clickable = visibility.visible && !disabled && !covered;
4008
+ let reason;
4009
+ if (!clickable) {
4010
+ const reasons = [];
4011
+ if (!visibility.visible) reasons.push("not visible");
4012
+ if (disabled) reasons.push("disabled");
4013
+ if (covered) reasons.push("covered by another element");
4014
+ reason = reasons.join(", ");
4015
+ }
4016
+ const element = interactiveEl ?? {
4017
+ ref,
4018
+ role: "generic",
4019
+ name: "",
4020
+ selector: match.selectorUsed ?? selector,
4021
+ disabled
4022
+ };
4023
+ const suggestedSelectors = generateSelectorStrings(element, snapshot);
4024
+ return {
4025
+ matched: true,
4026
+ selector: match.selectorUsed ?? selector,
4027
+ ref,
4028
+ element: {
4029
+ role: element.role,
4030
+ name: element.name,
4031
+ nodeId: match.nodeId,
4032
+ backendNodeId: match.backendNodeId
4033
+ },
4034
+ visibility,
4035
+ interactivity: {
4036
+ disabled,
4037
+ readonly,
4038
+ covered,
4039
+ coveringElement: covering ?? void 0,
4040
+ clickable,
4041
+ reason
4042
+ },
4043
+ attributes,
4044
+ suggestedSelectors
4045
+ };
4046
+ }
4047
+ }
4048
+ let candidates = snapshot.interactiveElements;
4049
+ if (!includeHidden) {
4050
+ candidates = candidates.filter((el) => !el.disabled);
4051
+ }
4052
+ const matches = fuzzyMatchElements(selector, candidates, maxCandidates);
4053
+ return {
4054
+ matched: false,
4055
+ query: selector,
4056
+ candidates: matches.map((m) => ({
4057
+ score: m.score,
4058
+ ref: m.element.ref,
4059
+ selector: m.element.selector,
4060
+ role: m.element.role,
4061
+ name: m.element.name,
4062
+ visible: true,
4063
+ // We assume visible since they're interactive
4064
+ disabled: m.element.disabled ?? false,
4065
+ matchReason: m.matchReason
4066
+ }))
4067
+ };
4068
+ }
4069
+
4070
+ // src/cli/commands/diagnose.ts
4071
+ var DIAGNOSE_HELP = `
4072
+ bp diagnose - Debug element selection and find alternatives
4073
+
4074
+ Usage:
4075
+ bp diagnose <selector> Diagnose specific selector
4076
+ bp diagnose "<fuzzy query>" Fuzzy search for elements
4077
+
4078
+ Examples:
4079
+ bp diagnose "#login-btn" Full diagnostics for element
4080
+ bp diagnose "submit" Find elements matching "submit"
4081
+ bp diagnose "ref:e4" Diagnose by element ref
4082
+
4083
+ Options:
4084
+ --json Output as JSON
4085
+ --max <n> Max candidates for fuzzy match (default: 5)
4086
+ -s, --session <id> Use specific session
4087
+ --help Show this help
4088
+
4089
+ Output (exact match):
4090
+ - Visibility: display, opacity, in viewport
4091
+ - Interactivity: disabled, covered by overlay
4092
+ - Alternative selectors
4093
+
4094
+ Output (fuzzy match):
4095
+ - Top N candidates ranked by similarity
4096
+ - Role, name, visibility for each
4097
+ `;
4098
+ function parseDiagnoseArgs(args) {
4099
+ const options = {};
4100
+ let selector;
4101
+ for (let i = 0; i < args.length; i++) {
4102
+ const arg = args[i];
4103
+ if (arg === "--max") {
4104
+ options.maxCandidates = parseInt(args[++i] ?? "5", 10);
4105
+ } else if (arg === "--help" || arg === "-h") {
4106
+ options.help = true;
4107
+ } else if (!arg.startsWith("-")) {
4108
+ selector = arg;
4109
+ }
4110
+ }
4111
+ return { selector, options };
4112
+ }
4113
+ function formatExactResult(result) {
4114
+ const lines = [];
4115
+ lines.push(`\u2713 Element Found: ${result.selector}`);
4116
+ lines.push(` Ref: ${result.ref}`);
4117
+ lines.push(` Role: ${result.element.role}`);
4118
+ if (result.element.name) {
4119
+ lines.push(` Name: "${result.element.name}"`);
4120
+ }
4121
+ lines.push("");
4122
+ lines.push("Visibility:");
4123
+ lines.push(` Visible: ${result.visibility.visible ? "\u2713 Yes" : "\u2717 No"}`);
4124
+ if (!result.visibility.visible && result.visibility.reasons.length > 0) {
4125
+ lines.push(` Reasons: ${result.visibility.reasons.join(", ")}`);
4126
+ }
4127
+ lines.push(` Display: ${result.visibility.display}`);
4128
+ lines.push(` Opacity: ${result.visibility.opacity}`);
4129
+ lines.push(` Size: ${result.visibility.width}x${result.visibility.height}`);
4130
+ lines.push(` In Viewport: ${result.visibility.inViewport ? "Yes" : "No"}`);
4131
+ lines.push("");
4132
+ lines.push("Interactivity:");
4133
+ lines.push(` Clickable: ${result.interactivity.clickable ? "\u2713 Yes" : "\u2717 No"}`);
4134
+ if (!result.interactivity.clickable && result.interactivity.reason) {
4135
+ lines.push(` Reason: ${result.interactivity.reason}`);
4136
+ }
4137
+ lines.push(` Disabled: ${result.interactivity.disabled ? "Yes" : "No"}`);
4138
+ lines.push(` Readonly: ${result.interactivity.readonly ? "Yes" : "No"}`);
4139
+ lines.push(` Covered: ${result.interactivity.covered ? "Yes" : "No"}`);
4140
+ if (result.interactivity.coveringElement) {
4141
+ const ce = result.interactivity.coveringElement;
4142
+ lines.push(` Covering Element: <${ce.tagName}${ce.id ? ` id="${ce.id}"` : ""}>`);
4143
+ }
4144
+ lines.push("");
4145
+ if (result.suggestedSelectors.length > 0) {
4146
+ lines.push("Alternative Selectors:");
4147
+ for (const sel of result.suggestedSelectors) {
4148
+ lines.push(` - ${sel}`);
4149
+ }
4150
+ }
4151
+ return lines.join("\n");
4152
+ }
4153
+ function formatFuzzyResult(result) {
4154
+ const lines = [];
4155
+ lines.push(`\u2717 No exact match for: "${result.query}"`);
4156
+ lines.push("");
4157
+ if (result.candidates.length === 0) {
4158
+ lines.push("No similar elements found.");
4159
+ return lines.join("\n");
4160
+ }
4161
+ lines.push(`Found ${result.candidates.length} similar elements:`);
4162
+ lines.push("");
4163
+ for (let i = 0; i < result.candidates.length; i++) {
4164
+ const c = result.candidates[i];
4165
+ const score = (c.score * 100).toFixed(0);
4166
+ lines.push(`${i + 1}. [${c.ref}] ${c.role} "${c.name || "(no name)"}" (${score}% match)`);
4167
+ lines.push(` Selector: ${c.selector}`);
4168
+ lines.push(` Reason: ${c.matchReason}`);
4169
+ if (c.disabled) {
4170
+ lines.push(` \u26A0 Disabled`);
4171
+ }
4172
+ lines.push("");
4173
+ }
4174
+ return lines.join("\n");
4175
+ }
4176
+ async function diagnoseCommand(args, globalOptions) {
4177
+ const { selector, options } = parseDiagnoseArgs(args);
4178
+ if (options.help || !selector) {
4179
+ console.log(DIAGNOSE_HELP);
4180
+ return;
4181
+ }
4182
+ let session;
4183
+ if (globalOptions.session) {
4184
+ session = await loadSession(globalOptions.session);
4185
+ } else {
4186
+ session = await getDefaultSession();
4187
+ if (!session) {
4188
+ throw new Error('No session found. Run "bp connect" first.');
4189
+ }
4190
+ }
4191
+ const browser = await connect({
4192
+ provider: session.provider,
4193
+ wsUrl: session.wsUrl,
4194
+ debug: globalOptions.trace
4195
+ });
4196
+ try {
4197
+ const page = await browser.page(void 0, { targetId: session.targetId });
4198
+ const result = await diagnoseElement(page, selector, {
4199
+ maxCandidates: options.maxCandidates
4200
+ });
4201
+ const snapshot = await page.snapshot();
4202
+ await updateSession(session.id, {
4203
+ currentUrl: snapshot.url,
4204
+ metadata: {
4205
+ refCache: {
4206
+ url: snapshot.url,
4207
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
4208
+ refMap: page.exportRefMap()
4209
+ }
4210
+ }
4211
+ });
4212
+ if (globalOptions.output === "json") {
4213
+ output(result, "json");
4214
+ } else {
4215
+ if (result.matched) {
4216
+ console.log(formatExactResult(result));
4217
+ } else {
4218
+ console.log(formatFuzzyResult(result));
4219
+ }
4220
+ }
4221
+ } finally {
4222
+ await browser.disconnect();
4223
+ }
4224
+ }
4225
+
4226
+ // src/cli/session-logger.ts
4227
+ var fs = __toESM(require("fs"), 1);
4228
+ var import_node_os2 = require("os");
4229
+ var import_node_path2 = require("path");
4230
+ var SESSION_DIR2 = (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".browser-pilot", "sessions");
4231
+ var SessionLogger = class {
4232
+ logPath;
4233
+ exportLogPath = null;
4234
+ seq = 0;
4235
+ constructor(sessionId, exportLogPath) {
4236
+ const sessionDir = (0, import_node_path2.join)(SESSION_DIR2, sessionId);
4237
+ this.logPath = (0, import_node_path2.join)(sessionDir, "log.jsonl");
4238
+ if (!fs.existsSync(sessionDir)) {
4239
+ fs.mkdirSync(sessionDir, { recursive: true });
4240
+ }
4241
+ if (exportLogPath) {
4242
+ this.exportLogPath = (0, import_node_path2.resolve)(exportLogPath);
4243
+ const exportDir = (0, import_node_path2.dirname)(this.exportLogPath);
4244
+ if (!fs.existsSync(exportDir)) {
4245
+ fs.mkdirSync(exportDir, { recursive: true });
4246
+ }
4247
+ }
4248
+ if (fs.existsSync(this.logPath)) {
4249
+ this.seq = this.countEntries();
4250
+ }
4251
+ }
4252
+ /**
4253
+ * Log a raw entry (writes to both core and export logs)
4254
+ */
4255
+ log(entry) {
4256
+ const fullEntry = {
4257
+ seq: ++this.seq,
4258
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4259
+ ...entry
4260
+ };
4261
+ const line = `${JSON.stringify(fullEntry)}
4262
+ `;
4263
+ fs.appendFileSync(this.logPath, line, "utf-8");
4264
+ if (this.exportLogPath) {
4265
+ try {
4266
+ fs.appendFileSync(this.exportLogPath, line, "utf-8");
4267
+ } catch (err) {
4268
+ console.warn(`[browser-pilot] Failed to write to export log: ${err}`);
4269
+ }
4270
+ }
4271
+ }
4272
+ /**
4273
+ * Get the export log path (if configured)
4274
+ */
4275
+ getExportLogPath() {
4276
+ return this.exportLogPath;
4277
+ }
4278
+ /**
4279
+ * Log a command execution
4280
+ */
4281
+ logCommand(cmd, args, result, durationMs) {
4282
+ this.log({
4283
+ type: "command",
4284
+ cmd,
4285
+ args,
4286
+ status: result.success ? "success" : "failed",
4287
+ durationMs,
4288
+ error: result.error,
4289
+ hints: result.hints
4290
+ });
4291
+ }
4292
+ /**
4293
+ * Log an error
4294
+ */
4295
+ logError(error, context) {
4296
+ this.log({
4297
+ type: "error",
4298
+ error: error.message,
4299
+ args: context
4300
+ });
4301
+ }
4302
+ /**
4303
+ * Get the log file path
4304
+ */
4305
+ getLogPath() {
4306
+ return this.logPath;
4307
+ }
4308
+ /**
4309
+ * Get log statistics
4310
+ */
4311
+ getLogStats() {
4312
+ if (!fs.existsSync(this.logPath)) {
4313
+ return { entries: 0, size: 0 };
4314
+ }
4315
+ const stat = fs.statSync(this.logPath);
4316
+ const entries = this.countEntries();
4317
+ let first;
4318
+ let last;
4319
+ if (entries > 0) {
4320
+ const lines = fs.readFileSync(this.logPath, "utf-8").trim().split("\n");
4321
+ const firstEntry = this.parseLine(lines[0]);
4322
+ const lastEntry = this.parseLine(lines[lines.length - 1]);
4323
+ first = firstEntry?.ts;
4324
+ last = lastEntry?.ts;
4325
+ }
4326
+ return {
4327
+ entries,
4328
+ size: stat.size,
4329
+ first,
4330
+ last
4331
+ };
4332
+ }
4333
+ /**
4334
+ * Get the last n log entries
4335
+ */
4336
+ tailLog(n) {
4337
+ if (!fs.existsSync(this.logPath)) {
4338
+ return [];
4339
+ }
4340
+ const content = fs.readFileSync(this.logPath, "utf-8").trim();
4341
+ if (!content) {
4342
+ return [];
4343
+ }
4344
+ const lines = content.split("\n");
4345
+ const startIndex = Math.max(0, lines.length - n);
4346
+ const result = [];
4347
+ for (let i = startIndex; i < lines.length; i++) {
4348
+ const entry = this.parseLine(lines[i]);
4349
+ if (entry) {
4350
+ result.push(entry);
4351
+ }
4352
+ }
4353
+ return result;
4354
+ }
4355
+ /**
4356
+ * Count entries in the log file
4357
+ */
4358
+ countEntries() {
4359
+ if (!fs.existsSync(this.logPath)) {
4360
+ return 0;
4361
+ }
4362
+ const content = fs.readFileSync(this.logPath, "utf-8").trim();
4363
+ if (!content) {
4364
+ return 0;
4365
+ }
4366
+ return content.split("\n").length;
4367
+ }
4368
+ /**
4369
+ * Parse a single log line
4370
+ */
4371
+ parseLine(line) {
4372
+ if (!line) {
4373
+ return null;
4374
+ }
4375
+ try {
4376
+ return JSON.parse(line);
4377
+ } catch {
4378
+ return null;
4379
+ }
4380
+ }
4381
+ };
4382
+ var loggerCache = /* @__PURE__ */ new Map();
4383
+ function getSessionLogger(sessionId, exportLogPath) {
4384
+ const cacheKey = exportLogPath ? `${sessionId}:${exportLogPath}` : sessionId;
4385
+ let logger = loggerCache.get(cacheKey);
4386
+ if (!logger) {
4387
+ logger = new SessionLogger(sessionId, exportLogPath);
4388
+ loggerCache.set(cacheKey, logger);
4389
+ }
4390
+ return logger;
4391
+ }
4392
+
3252
4393
  // src/cli/commands/exec.ts
3253
4394
  async function validateSession(session) {
3254
4395
  try {
@@ -3313,13 +4454,14 @@ Run 'bp actions' for complete action reference.`
3313
4454
  Session file has been cleaned up. Run "bp connect" to create a new session.`
3314
4455
  );
3315
4456
  }
4457
+ const logger = getSessionLogger(session.id, session.exportLog);
3316
4458
  const browser = await connect({
3317
4459
  provider: session.provider,
3318
4460
  wsUrl: session.wsUrl,
3319
4461
  debug: globalOptions.trace
3320
4462
  });
3321
4463
  try {
3322
- const page = addBatchToPage(await browser.page());
4464
+ const page = addBatchToPage(await browser.page(void 0, { targetId: session.targetId }));
3323
4465
  const currentUrlForCache = await page.url();
3324
4466
  const refCache = session.metadata?.refCache;
3325
4467
  if (refCache && refCache.url === currentUrlForCache) {
@@ -3335,7 +4477,30 @@ Session file has been cleaned up. Run "bp connect" to create a new session.`
3335
4477
  });
3336
4478
  }
3337
4479
  const steps = Array.isArray(actions) ? actions : [actions];
4480
+ const urlBefore = await page.url();
3338
4481
  const result = await page.batch(steps);
4482
+ const urlAfter = await page.url();
4483
+ for (const stepResult of result.steps) {
4484
+ logger.logCommand(
4485
+ stepResult.action,
4486
+ { selector: stepResult.selectorUsed },
4487
+ {
4488
+ success: stepResult.success,
4489
+ error: stepResult.error,
4490
+ hints: stepResult.hints
4491
+ },
4492
+ stepResult.durationMs
4493
+ );
4494
+ }
4495
+ logger.log({
4496
+ type: "event",
4497
+ cmd: "batch",
4498
+ args: { stepCount: steps.length },
4499
+ status: result.success ? "success" : "failed",
4500
+ durationMs: result.totalDurationMs,
4501
+ urlBefore,
4502
+ urlAfter
4503
+ });
3339
4504
  const currentUrl = await page.url();
3340
4505
  const hasSnapshot = steps.some((step) => step.action === "snapshot");
3341
4506
  if (hasSnapshot) {
@@ -3376,7 +4541,101 @@ Session file has been cleaned up. Run "bp connect" to create a new session.`
3376
4541
  }
3377
4542
 
3378
4543
  // src/cli/commands/list.ts
3379
- async function listCommand(_args, globalOptions) {
4544
+ function parseListArgs(args) {
4545
+ const options = {};
4546
+ for (let i = 0; i < args.length; i++) {
4547
+ const arg = args[i];
4548
+ if (arg === "--log-path") {
4549
+ options.logPath = true;
4550
+ } else if (arg === "--log-tail") {
4551
+ const next = args[i + 1];
4552
+ if (next && !next.startsWith("-")) {
4553
+ options.logTail = parseInt(next, 10);
4554
+ i++;
4555
+ } else {
4556
+ options.logTail = 20;
4557
+ }
4558
+ } else if (arg === "--info") {
4559
+ options.info = true;
4560
+ }
4561
+ }
4562
+ return options;
4563
+ }
4564
+ function formatLogEntry(entry) {
4565
+ const time = new Date(entry.ts).toLocaleTimeString();
4566
+ const status = entry.status === "failed" ? "\u2717" : entry.status === "success" ? "\u2713" : "\u25CB";
4567
+ if (entry.type === "command") {
4568
+ const dur = entry.durationMs ? `(${entry.durationMs}ms)` : "";
4569
+ return `${time} ${status} ${entry.cmd} ${dur}`;
4570
+ }
4571
+ if (entry.type === "error") {
4572
+ return `${time} \u2717 ERROR: ${entry.error}`;
4573
+ }
4574
+ return `${time} ${entry.type}: ${entry.cmd || ""}`;
4575
+ }
4576
+ async function listCommand(args, globalOptions) {
4577
+ const listOptions = parseListArgs(args);
4578
+ if (listOptions.logPath || listOptions.logTail !== void 0 || listOptions.info) {
4579
+ let session;
4580
+ if (globalOptions.session) {
4581
+ session = await loadSession(globalOptions.session);
4582
+ } else {
4583
+ session = await getDefaultSession();
4584
+ }
4585
+ if (!session) {
4586
+ throw new Error('No session found. Run "bp connect" first or specify with -s.');
4587
+ }
4588
+ const logger = getSessionLogger(session.id);
4589
+ if (listOptions.logPath) {
4590
+ console.log(logger.getLogPath());
4591
+ return;
4592
+ }
4593
+ if (listOptions.logTail !== void 0) {
4594
+ const entries = logger.tailLog(listOptions.logTail);
4595
+ if (globalOptions.output === "json") {
4596
+ output(entries, "json");
4597
+ return;
4598
+ }
4599
+ if (entries.length === 0) {
4600
+ console.log("No log entries.");
4601
+ return;
4602
+ }
4603
+ console.log(`Last ${entries.length} log entries for session ${session.id}:
4604
+ `);
4605
+ for (const entry of entries) {
4606
+ console.log(` ${formatLogEntry(entry)}`);
4607
+ }
4608
+ return;
4609
+ }
4610
+ if (listOptions.info) {
4611
+ const stats = logger.getLogStats();
4612
+ if (globalOptions.output === "json") {
4613
+ output({ session, logStats: stats }, "json");
4614
+ return;
4615
+ }
4616
+ console.log(`Session: ${session.id}
4617
+ `);
4618
+ console.log(` Provider: ${session.provider}`);
4619
+ console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
4620
+ console.log(` Last activity: ${new Date(session.lastActivity).toLocaleString()}`);
4621
+ console.log(` URL: ${session.currentUrl}`);
4622
+ if (session.exportLog) {
4623
+ console.log(` Export log: ${session.exportLog}`);
4624
+ }
4625
+ console.log("");
4626
+ console.log("Log Stats:");
4627
+ console.log(` Path: ${logger.getLogPath()}`);
4628
+ console.log(` Entries: ${stats.entries}`);
4629
+ console.log(` Size: ${formatBytes(stats.size)}`);
4630
+ if (stats.first) {
4631
+ console.log(` First: ${new Date(stats.first).toLocaleString()}`);
4632
+ }
4633
+ if (stats.last) {
4634
+ console.log(` Last: ${new Date(stats.last).toLocaleString()}`);
4635
+ }
4636
+ return;
4637
+ }
4638
+ }
3380
4639
  const sessions = await listSessions();
3381
4640
  if (globalOptions.output === "json") {
3382
4641
  output(sessions, "json");
@@ -3397,6 +4656,11 @@ async function listCommand(_args, globalOptions) {
3397
4656
  console.log("");
3398
4657
  }
3399
4658
  }
4659
+ function formatBytes(bytes) {
4660
+ if (bytes < 1024) return `${bytes} B`;
4661
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4662
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
4663
+ }
3400
4664
  function getAge(date) {
3401
4665
  const now = Date.now();
3402
4666
  const diff = now - date.getTime();
@@ -3442,9 +4706,9 @@ STEP 5: BATCH MULTIPLE ACTIONS
3442
4706
  ]'
3443
4707
 
3444
4708
  FOR AI AGENTS
3445
- Use -o json for machine-readable output:
3446
- bp snapshot --format text -o json
3447
- bp exec '{"action":"click","selector":"ref:e3"}' -o json
4709
+ Use --json for machine-readable output:
4710
+ bp snapshot --format text --json
4711
+ bp exec '{"action":"click","selector":"ref:e3"}' --json
3448
4712
 
3449
4713
  TIPS
3450
4714
  \u2022 Refs (e1, e2...) are stable within a page - prefer them over CSS selectors
@@ -3476,6 +4740,31 @@ RECORDING (FOR HUMANS)
3476
4740
 
3477
4741
  Great for creating initial automation scripts that AI agents can refine.
3478
4742
 
4743
+ DEBUGGING
4744
+ When element selection fails, use these tools to diagnose:
4745
+
4746
+ 1. Diagnose a selector:
4747
+ bp diagnose '#submit-button' -s mysite
4748
+
4749
+ Shows exact matches, fuzzy matches, visibility issues, and suggestions.
4750
+
4751
+ 2. Compare page states:
4752
+ bp snapshot > before.json
4753
+ # ... perform actions ...
4754
+ bp snapshot --diff before.json
4755
+
4756
+ Shows what changed: added/removed/modified elements.
4757
+
4758
+ 3. Visual inspection:
4759
+ bp snapshot --inspect
4760
+
4761
+ Injects visual ref labels onto the page. Use --keep to leave them visible.
4762
+
4763
+ 4. Session logs:
4764
+ bp list -s mysite --log-tail 10
4765
+
4766
+ Shows last N commands with timing and any errors.
4767
+
3479
4768
  Run 'bp actions' for the complete action reference.
3480
4769
  `;
3481
4770
  async function quickstartCommand() {
@@ -4433,8 +5722,8 @@ async function recordCommand(args, globalOptions) {
4433
5722
  stopping = true;
4434
5723
  try {
4435
5724
  const recording = await recorder.stop();
4436
- const fs = await import("fs/promises");
4437
- await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
5725
+ const fs3 = await import("fs/promises");
5726
+ await fs3.writeFile(outputFile, JSON.stringify(recording, null, 2));
4438
5727
  const currentUrl = await page.url();
4439
5728
  await updateSession(session.id, { currentUrl });
4440
5729
  await browser.disconnect();
@@ -4502,7 +5791,7 @@ async function screenshotCommand(args, globalOptions) {
4502
5791
  debug: globalOptions.trace
4503
5792
  });
4504
5793
  try {
4505
- const page = await browser.page();
5794
+ const page = await browser.page(void 0, { targetId: session.targetId });
4506
5795
  const screenshotData = await page.screenshot({
4507
5796
  format: options.format ?? "png",
4508
5797
  quality: options.quality,
@@ -4532,6 +5821,256 @@ async function screenshotCommand(args, globalOptions) {
4532
5821
  }
4533
5822
  }
4534
5823
 
5824
+ // src/cli/commands/snapshot.ts
5825
+ var fs2 = __toESM(require("fs"), 1);
5826
+
5827
+ // src/browser/overlay.ts
5828
+ var OVERLAY_SCRIPT = `(function() {
5829
+ // Check for existing DOM elements (handles cross-CLI reconnection)
5830
+ let style = document.getElementById('__bp-overlay-styles');
5831
+ let container = document.getElementById('__bp-overlay-container');
5832
+
5833
+ // Clear existing labels before updating
5834
+ if (container) {
5835
+ container.innerHTML = '';
5836
+ }
5837
+ document.querySelectorAll('[data-bp-ref]').forEach(el => el.removeAttribute('data-bp-ref'));
5838
+
5839
+ // Create infrastructure only if it doesn't exist
5840
+ if (!style) {
5841
+ style = document.createElement('style');
5842
+ style.id = '__bp-overlay-styles';
5843
+ style.textContent = \`
5844
+ [data-bp-ref] {
5845
+ outline: 2px dashed rgba(229, 57, 53, 0.6) !important;
5846
+ outline-offset: 2px !important;
5847
+ }
5848
+ .__bp-ref-label {
5849
+ position: absolute;
5850
+ background: #e53935;
5851
+ color: white;
5852
+ padding: 1px 4px;
5853
+ font-size: 10px;
5854
+ font-family: monospace;
5855
+ font-weight: bold;
5856
+ z-index: 10000;
5857
+ pointer-events: none;
5858
+ border-radius: 2px;
5859
+ line-height: 1.2;
5860
+ }
5861
+ \`;
5862
+ document.head.appendChild(style);
5863
+ }
5864
+
5865
+ if (!container) {
5866
+ container = document.createElement('div');
5867
+ container.id = '__bp-overlay-container';
5868
+ container.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10000;';
5869
+ document.body.appendChild(container);
5870
+ }
5871
+
5872
+ // Always redefine to ensure correct container reference
5873
+ window.__bpAddLabel = function(ref, rect) {
5874
+ const label = document.createElement('div');
5875
+ label.className = '__bp-ref-label';
5876
+ label.textContent = ref;
5877
+ label.style.left = (rect.left + window.scrollX) + 'px';
5878
+ label.style.top = (rect.top + window.scrollY - 16) + 'px';
5879
+ container.appendChild(label);
5880
+ };
5881
+
5882
+ window.__bpRemoveOverlay = function() {
5883
+ const c = document.getElementById('__bp-overlay-container');
5884
+ const s = document.getElementById('__bp-overlay-styles');
5885
+ if (c) c.remove();
5886
+ if (s) s.remove();
5887
+ document.querySelectorAll('[data-bp-ref]').forEach(el => el.removeAttribute('data-bp-ref'));
5888
+ delete window.__bpOverlayInstalled;
5889
+ delete window.__bpAddLabel;
5890
+ delete window.__bpRemoveOverlay;
5891
+ };
5892
+
5893
+ window.__bpOverlayInstalled = true;
5894
+ })();`;
5895
+ var REMOVE_OVERLAY_SCRIPT = `(function() {
5896
+ if (window.__bpRemoveOverlay) {
5897
+ window.__bpRemoveOverlay();
5898
+ }
5899
+ })();`;
5900
+ var ADD_REF_SCRIPT = `function(ref) {
5901
+ this.setAttribute('data-bp-ref', ref);
5902
+ const rect = this.getBoundingClientRect();
5903
+ if (window.__bpAddLabel) {
5904
+ window.__bpAddLabel(ref, {
5905
+ left: rect.left,
5906
+ top: rect.top,
5907
+ width: rect.width,
5908
+ height: rect.height
5909
+ });
5910
+ }
5911
+ return true;
5912
+ }`;
5913
+ async function injectRefOverlay(page, snapshot) {
5914
+ await page.evaluate(OVERLAY_SCRIPT);
5915
+ const refMap = page.exportRefMap();
5916
+ const cdp = page.cdpClient;
5917
+ for (const element of snapshot.interactiveElements) {
5918
+ const backendNodeId = refMap[element.ref];
5919
+ if (backendNodeId === void 0) {
5920
+ continue;
5921
+ }
5922
+ try {
5923
+ const resolveResult = await cdp.send("DOM.resolveNode", {
5924
+ backendNodeId
5925
+ });
5926
+ if (!resolveResult.object?.objectId) {
5927
+ continue;
5928
+ }
5929
+ await cdp.send("Runtime.callFunctionOn", {
5930
+ objectId: resolveResult.object.objectId,
5931
+ functionDeclaration: ADD_REF_SCRIPT,
5932
+ arguments: [{ value: element.ref }],
5933
+ returnByValue: true
5934
+ });
5935
+ } catch {
5936
+ }
5937
+ }
5938
+ }
5939
+ async function removeRefOverlay(page) {
5940
+ await page.evaluate(REMOVE_OVERLAY_SCRIPT);
5941
+ }
5942
+
5943
+ // src/browser/snapshot-diff.ts
5944
+ function getElementKey(node, path) {
5945
+ const name = node.name ?? "";
5946
+ return `${node.role}::${name}::${path.join("/")}`;
5947
+ }
5948
+ function flattenTree(nodes, path = []) {
5949
+ const result = [];
5950
+ for (let i = 0; i < nodes.length; i++) {
5951
+ const node = nodes[i];
5952
+ const currentPath = [...path, String(i)];
5953
+ const key = getElementKey(node, currentPath);
5954
+ result.push({ node, key, path: currentPath });
5955
+ if (node.children) {
5956
+ result.push(...flattenTree(node.children, currentPath));
5957
+ }
5958
+ }
5959
+ return result;
5960
+ }
5961
+ function compareNodes(before, after) {
5962
+ const changedFields = [];
5963
+ if (before.role !== after.role) {
5964
+ changedFields.push("role");
5965
+ }
5966
+ if (before.name !== after.name) {
5967
+ changedFields.push("name");
5968
+ }
5969
+ if (before.value !== after.value) {
5970
+ changedFields.push("value");
5971
+ }
5972
+ if (before.disabled !== after.disabled) {
5973
+ changedFields.push("disabled");
5974
+ }
5975
+ if (before.checked !== after.checked) {
5976
+ changedFields.push("checked");
5977
+ }
5978
+ return changedFields;
5979
+ }
5980
+ function diffSnapshots(before, after) {
5981
+ const beforeFlat = flattenTree(before.accessibilityTree);
5982
+ const afterFlat = flattenTree(after.accessibilityTree);
5983
+ const beforeMap = new Map(beforeFlat.map((item) => [item.key, item]));
5984
+ const afterMap = new Map(afterFlat.map((item) => [item.key, item]));
5985
+ const added = [];
5986
+ const removed = [];
5987
+ const changed = [];
5988
+ let unchanged = 0;
5989
+ for (const afterItem of afterFlat) {
5990
+ const beforeItem = beforeMap.get(afterItem.key);
5991
+ if (!beforeItem) {
5992
+ added.push(afterItem.node);
5993
+ } else {
5994
+ const changedFields = compareNodes(beforeItem.node, afterItem.node);
5995
+ if (changedFields.length > 0) {
5996
+ changed.push({
5997
+ key: afterItem.key,
5998
+ before: beforeItem.node,
5999
+ after: afterItem.node,
6000
+ changedFields
6001
+ });
6002
+ } else {
6003
+ unchanged++;
6004
+ }
6005
+ }
6006
+ }
6007
+ for (const beforeItem of beforeFlat) {
6008
+ if (!afterMap.has(beforeItem.key)) {
6009
+ removed.push(beforeItem.node);
6010
+ }
6011
+ }
6012
+ return {
6013
+ metadata: {
6014
+ before: {
6015
+ url: before.url,
6016
+ timestamp: before.timestamp,
6017
+ title: before.title
6018
+ },
6019
+ after: {
6020
+ url: after.url,
6021
+ timestamp: after.timestamp,
6022
+ title: after.title
6023
+ },
6024
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
6025
+ },
6026
+ summary: {
6027
+ added: added.length,
6028
+ removed: removed.length,
6029
+ changed: changed.length,
6030
+ unchanged
6031
+ },
6032
+ changes: {
6033
+ added,
6034
+ removed,
6035
+ changed
6036
+ }
6037
+ };
6038
+ }
6039
+ function formatDiffPretty(diff) {
6040
+ const lines = [];
6041
+ lines.push(`Snapshot Diff: ${diff.metadata.after.url}`);
6042
+ lines.push(` Before: ${diff.metadata.before.timestamp}`);
6043
+ lines.push(` After: ${diff.metadata.after.timestamp}`);
6044
+ lines.push("");
6045
+ if (diff.summary.added === 0 && diff.summary.removed === 0 && diff.summary.changed === 0) {
6046
+ lines.push("No changes detected.");
6047
+ return lines.join("\n");
6048
+ }
6049
+ lines.push("Changes:");
6050
+ for (const node of diff.changes.added) {
6051
+ const name = node.name ? ` "${node.name}"` : "";
6052
+ lines.push(` + [${node.ref}] ${node.role}${name} (new)`);
6053
+ }
6054
+ for (const item of diff.changes.changed) {
6055
+ const name = item.after.name ? ` "${item.after.name}"` : "";
6056
+ const fieldChanges = item.changedFields.map((field) => {
6057
+ const beforeVal = item.before[field];
6058
+ const afterVal = item.after[field];
6059
+ return `${field}: ${JSON.stringify(beforeVal)} \u2192 ${JSON.stringify(afterVal)}`;
6060
+ }).join(", ");
6061
+ lines.push(` ~ [${item.after.ref}] ${item.after.role}${name} ${fieldChanges}`);
6062
+ }
6063
+ for (const node of diff.changes.removed) {
6064
+ const name = node.name ? ` "${node.name}"` : "";
6065
+ lines.push(` - [${node.ref}] ${node.role}${name} (removed)`);
6066
+ }
6067
+ lines.push("");
6068
+ lines.push(
6069
+ `Summary: +${diff.summary.added} added, -${diff.summary.removed} removed, ~${diff.summary.changed} changed`
6070
+ );
6071
+ return lines.join("\n");
6072
+ }
6073
+
4535
6074
  // src/cli/commands/snapshot.ts
4536
6075
  function parseSnapshotArgs(args) {
4537
6076
  const options = {};
@@ -4539,10 +6078,19 @@ function parseSnapshotArgs(args) {
4539
6078
  const arg = args[i];
4540
6079
  if (arg === "--format" || arg === "-f") {
4541
6080
  options.format = args[++i];
6081
+ } else if (arg === "--diff" || arg === "-d") {
6082
+ options.diffFile = args[++i];
6083
+ } else if (arg === "--inspect") {
6084
+ options.inspect = true;
6085
+ } else if (arg === "--keep") {
6086
+ options.keep = true;
4542
6087
  }
4543
6088
  }
4544
6089
  return options;
4545
6090
  }
6091
+ function sleep3(ms) {
6092
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
6093
+ }
4546
6094
  async function snapshotCommand(args, globalOptions) {
4547
6095
  const options = parseSnapshotArgs(args);
4548
6096
  let session;
@@ -4560,7 +6108,7 @@ async function snapshotCommand(args, globalOptions) {
4560
6108
  debug: globalOptions.trace
4561
6109
  });
4562
6110
  try {
4563
- const page = await browser.page();
6111
+ const page = await browser.page(void 0, { targetId: session.targetId });
4564
6112
  const snapshot = await page.snapshot();
4565
6113
  await updateSession(session.id, {
4566
6114
  currentUrl: snapshot.url,
@@ -4572,6 +6120,34 @@ async function snapshotCommand(args, globalOptions) {
4572
6120
  }
4573
6121
  }
4574
6122
  });
6123
+ if (options.diffFile) {
6124
+ if (!fs2.existsSync(options.diffFile)) {
6125
+ throw new Error(`Diff file not found: ${options.diffFile}`);
6126
+ }
6127
+ const beforeContent = fs2.readFileSync(options.diffFile, "utf-8");
6128
+ const beforeSnapshot = JSON.parse(beforeContent);
6129
+ const diff = diffSnapshots(beforeSnapshot, snapshot);
6130
+ if (globalOptions.output === "json") {
6131
+ output(diff, "json");
6132
+ } else {
6133
+ console.log(formatDiffPretty(diff));
6134
+ }
6135
+ return;
6136
+ }
6137
+ if (options.inspect) {
6138
+ await injectRefOverlay(page, snapshot);
6139
+ console.log("Overlay injected. Element refs are now visible on the page.");
6140
+ if (options.keep) {
6141
+ console.log(
6142
+ "Overlay will remain visible. Use removeRefOverlay() or refresh the page to remove."
6143
+ );
6144
+ } else {
6145
+ console.log("Overlay will be removed in 10 seconds...");
6146
+ await sleep3(1e4);
6147
+ await removeRefOverlay(page);
6148
+ console.log("Overlay removed.");
6149
+ }
6150
+ }
4575
6151
  switch (options.format) {
4576
6152
  case "interactive":
4577
6153
  output(snapshot.interactiveElements, globalOptions.output);
@@ -4616,7 +6192,7 @@ async function textCommand(args, globalOptions) {
4616
6192
  debug: globalOptions.trace
4617
6193
  });
4618
6194
  try {
4619
- const page = await browser.page();
6195
+ const page = await browser.page(void 0, { targetId: session.targetId });
4620
6196
  const text = await page.text(options.selector);
4621
6197
  const currentUrl = await page.url();
4622
6198
  await updateSession(session.id, { currentUrl });
@@ -4643,27 +6219,29 @@ Commands:
4643
6219
  exec Execute actions
4644
6220
  record Record browser actions to JSON
4645
6221
  snapshot Get page with element refs
6222
+ diagnose Debug element selection issues
4646
6223
  text Extract text content
4647
6224
  screenshot Take screenshot
4648
6225
  close Close session
4649
- list List sessions
6226
+ list List sessions (--log-path, --log-tail, --info)
4650
6227
  clean Clean up old sessions
4651
6228
  actions Complete action reference
4652
6229
 
4653
6230
  Options:
4654
6231
  -s, --session <id> Session ID
4655
6232
  -o, --output <fmt> json | pretty (default: pretty)
6233
+ --json Alias for -o json
4656
6234
  --trace Enable debug tracing
4657
6235
  --dialog <mode> Handle dialogs: accept | dismiss
4658
6236
  -h, --help Show help
4659
6237
 
4660
6238
  Examples:
4661
6239
  bp connect --provider generic --name dev
6240
+ bp connect -s test --export-log ./logs/test.jsonl
4662
6241
  bp exec '{"action":"goto","url":"https://example.com"}'
4663
6242
  bp snapshot --format text
6243
+ bp list --json # JSON output
4664
6244
  bp exec '{"action":"click","selector":"ref:e3"}'
4665
- bp record # Record from local browser
4666
- bp record -s -f login.json # Record from latest session
4667
6245
 
4668
6246
  Run 'bp quickstart' for CLI workflow guide.
4669
6247
  Run 'bp actions' for complete action reference.
@@ -4679,6 +6257,10 @@ function parseGlobalOptions(args) {
4679
6257
  options.session = args[++i];
4680
6258
  } else if (arg === "-o" || arg === "--output") {
4681
6259
  options.output = args[++i];
6260
+ } else if (arg === "--json") {
6261
+ options.output = "json";
6262
+ } else if (arg === "--pretty") {
6263
+ options.output = "pretty";
4682
6264
  } else if (arg === "--trace") {
4683
6265
  options.trace = true;
4684
6266
  } else if (arg === "-h" || arg === "--help") {
@@ -4698,7 +6280,7 @@ function output(data, format = "pretty") {
4698
6280
  } else if (typeof data === "object" && data !== null) {
4699
6281
  const { truncated } = prettyPrint(data);
4700
6282
  if (truncated) {
4701
- console.log("\n(Output truncated. Use -o json for full data)");
6283
+ console.log("\n(Output truncated. Use --json for full data)");
4702
6284
  }
4703
6285
  } else {
4704
6286
  console.log(data);
@@ -4748,6 +6330,9 @@ async function main() {
4748
6330
  case "snapshot":
4749
6331
  await snapshotCommand(remaining, options);
4750
6332
  break;
6333
+ case "diagnose":
6334
+ await diagnoseCommand(remaining, options);
6335
+ break;
4751
6336
  case "text":
4752
6337
  await textCommand(remaining, options);
4753
6338
  break;