browser-pilot 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -72,6 +72,12 @@ CONTENT EXTRACTION
72
72
  {"action": "snapshot"}
73
73
  Get accessibility tree (best for understanding page structure).
74
74
 
75
+ {"action": "forms"}
76
+ List form controls with labels, values, checked state, and options metadata.
77
+
78
+ {"action": "text"}
79
+ Extract visible page text.
80
+
75
81
  {"action": "screenshot"}
76
82
  {"action": "screenshot", "fullPage": true, "format": "jpeg", "quality": 80}
77
83
  Capture screenshot. Formats: png | jpeg | webp.
@@ -79,6 +85,15 @@ CONTENT EXTRACTION
79
85
  {"action": "evaluate", "value": "document.title"}
80
86
  Run JavaScript and return result.
81
87
 
88
+ TAB MANAGEMENT
89
+ {"action": "newTab"}
90
+ {"action": "newTab", "url": "https://example.com"}
91
+ Create a new tab and optionally navigate it. Returns { targetId }.
92
+
93
+ {"action": "closeTab"}
94
+ {"action": "closeTab", "targetId": "TARGET_ID"}
95
+ Close the current tab or a specific target by ID.
96
+
82
97
  IFRAME NAVIGATION
83
98
  {"action": "switchFrame", "selector": "iframe#checkout"}
84
99
  Switch context to an iframe. All subsequent actions target the iframe content.
@@ -110,13 +125,19 @@ COMMON OPTIONS (all actions)
110
125
 
111
126
  REF SELECTORS (from snapshot)
112
127
  After taking a snapshot, use refs directly:
113
- bp snapshot -s dev --format text # Shows: button "Submit" [ref=e4]
128
+ bp snapshot -s dev --format text # Shows: button "Submit" ref:e4
114
129
  bp exec '{"action":"click","selector":"ref:e4"}'
115
130
 
116
131
  Refs are stable until navigation. Prefix with "ref:" to use.
117
132
  CLI caches refs per session+URL after snapshot, so they can be reused across exec calls.
118
133
  Example: {"action":"fill","selector":"ref:e23","value":"hello"}
119
134
 
135
+ TEXT / ROLE SELECTORS
136
+ text:Continue Match by accessible text/name (partial match)
137
+ text:="Save Draft" Exact text match
138
+ role:button:Continue Match by role and optional accessible name
139
+ role:textbox:Email Useful when stable CSS selectors are missing
140
+
120
141
  MULTI-SELECTOR PATTERN
121
142
  All selectors accept arrays: ["#id", ".class", "[aria-label=X]"]
122
143
  Tries each in order until one succeeds.
@@ -129,6 +150,14 @@ SELECTOR PRIORITY (Most to Least Reliable)
129
150
  4. [aria-label="..."] - Good for buttons without testids
130
151
  5. Multi-selector array - Fallback pattern for compatibility
131
152
 
153
+ ASSERTIONS
154
+ {"action":"assertVisible","selector":"#success"}
155
+ {"action":"assertExists","selector":"#mounted-node"}
156
+ {"action":"assertText","expect":"Welcome back"}
157
+ {"action":"assertUrl","expect":"/dashboard"}
158
+ {"action":"assertValue","selector":"#email","expect":"user@example.com"}
159
+ Assertion steps verify state inline inside a batch workflow.
160
+
132
161
  SHADOW DOM
133
162
  Selectors automatically pierce shadow DOM (1-2 levels). No special syntax needed.
134
163
  For deeper nesting (3+ levels), use refs from snapshot - they work at any depth.
@@ -1289,6 +1318,9 @@ var BatchExecutor = class {
1289
1318
  const snapshot = await this.page.snapshot();
1290
1319
  return { value: snapshot };
1291
1320
  }
1321
+ case "forms": {
1322
+ return { value: await this.page.forms() };
1323
+ }
1292
1324
  case "screenshot": {
1293
1325
  const data = await this.page.screenshot({
1294
1326
  format: step.format,
@@ -1308,6 +1340,21 @@ var BatchExecutor = class {
1308
1340
  const text = await this.page.text(selector);
1309
1341
  return { text, selectorUsed: selector };
1310
1342
  }
1343
+ case "newTab": {
1344
+ const { targetId } = await this.page.cdpClient.send(
1345
+ "Target.createTarget",
1346
+ {
1347
+ url: step.url ?? "about:blank"
1348
+ },
1349
+ null
1350
+ );
1351
+ return { value: { targetId } };
1352
+ }
1353
+ case "closeTab": {
1354
+ const targetId = step.targetId ?? this.page.targetId;
1355
+ await this.page.cdpClient.send("Target.closeTarget", { targetId }, null);
1356
+ return { value: { targetId, closedCurrent: targetId === this.page.targetId } };
1357
+ }
1311
1358
  case "switchFrame": {
1312
1359
  if (!step.selector) throw new Error("switchFrame requires selector");
1313
1360
  await this.page.switchToFrame(step.selector, { timeout, optional });
@@ -1422,10 +1469,15 @@ var BatchExecutor = class {
1422
1469
  snap: "snapshot",
1423
1470
  accessibility: "snapshot",
1424
1471
  a11y: "snapshot",
1472
+ formslist: "forms",
1425
1473
  image: "screenshot",
1426
1474
  pic: "screenshot",
1427
1475
  frame: "switchFrame",
1428
1476
  iframe: "switchFrame",
1477
+ newtab: "newTab",
1478
+ opentab: "newTab",
1479
+ createtab: "newTab",
1480
+ closetab: "closeTab",
1429
1481
  assert_visible: "assertVisible",
1430
1482
  assert_exists: "assertExists",
1431
1483
  assert_text: "assertText",
@@ -1439,7 +1491,7 @@ var BatchExecutor = class {
1439
1491
  };
1440
1492
  const suggestion = aliases[action.toLowerCase()];
1441
1493
  const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
1442
- const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, screenshot, evaluate, text, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue";
1494
+ const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, forms, screenshot, evaluate, text, newTab, closeTab, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue";
1443
1495
  throw new Error(`Unknown action "${action}".${hint}
1444
1496
 
1445
1497
  Valid actions: ${valid}`);
@@ -1510,6 +1562,11 @@ var ACTION_ALIASES = {
1510
1562
  pic: "screenshot",
1511
1563
  frame: "switchFrame",
1512
1564
  iframe: "switchFrame",
1565
+ formslist: "forms",
1566
+ newtab: "newTab",
1567
+ opentab: "newTab",
1568
+ createtab: "newTab",
1569
+ closetab: "closeTab",
1513
1570
  assert_visible: "assertVisible",
1514
1571
  assert_exists: "assertExists",
1515
1572
  assert_text: "assertText",
@@ -1546,7 +1603,8 @@ var PROPERTY_ALIASES = {
1546
1603
  button: "key",
1547
1604
  address: "url",
1548
1605
  page: "url",
1549
- path: "url"
1606
+ path: "url",
1607
+ tabId: "targetId"
1550
1608
  };
1551
1609
  var ACTION_RULES = {
1552
1610
  goto: {
@@ -1647,6 +1705,10 @@ var ACTION_RULES = {
1647
1705
  fullPage: { type: "boolean" }
1648
1706
  }
1649
1707
  },
1708
+ forms: {
1709
+ required: {},
1710
+ optional: {}
1711
+ },
1650
1712
  evaluate: {
1651
1713
  required: { value: { type: "string" } },
1652
1714
  optional: {}
@@ -1661,6 +1723,18 @@ var ACTION_RULES = {
1661
1723
  required: { selector: { type: "string|string[]" } },
1662
1724
  optional: {}
1663
1725
  },
1726
+ newTab: {
1727
+ required: {},
1728
+ optional: {
1729
+ url: { type: "string" }
1730
+ }
1731
+ },
1732
+ closeTab: {
1733
+ required: {},
1734
+ optional: {
1735
+ targetId: { type: "string" }
1736
+ }
1737
+ },
1664
1738
  switchToMain: {
1665
1739
  required: {},
1666
1740
  optional: {}
@@ -1703,6 +1777,7 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
1703
1777
  "selector",
1704
1778
  "url",
1705
1779
  "value",
1780
+ "targetId",
1706
1781
  "key",
1707
1782
  "combo",
1708
1783
  "modifiers",
@@ -1857,15 +1932,22 @@ function validateSteps(steps) {
1857
1932
  const rule = ACTION_RULES[action];
1858
1933
  for (const key of Object.keys(obj)) {
1859
1934
  if (key === "action") continue;
1860
- if (!KNOWN_STEP_FIELDS.has(key)) {
1861
- const suggestion = suggestProperty(key);
1862
- errors.push({
1863
- stepIndex: i,
1864
- field: key,
1865
- message: suggestion ? `unknown property "${key}". Did you mean "${suggestion}"?` : `unknown property "${key}".`,
1866
- suggestion: suggestion ? `Did you mean "${suggestion}"?` : void 0
1867
- });
1935
+ if (KNOWN_STEP_FIELDS.has(key)) continue;
1936
+ const canonical = PROPERTY_ALIASES[key];
1937
+ if (canonical) {
1938
+ if (!(canonical in obj)) {
1939
+ obj[canonical] = obj[key];
1940
+ }
1941
+ delete obj[key];
1942
+ continue;
1868
1943
  }
1944
+ const suggestion = suggestProperty(key);
1945
+ errors.push({
1946
+ stepIndex: i,
1947
+ field: key,
1948
+ message: suggestion ? `unknown property "${key}". Did you mean "${suggestion}"?` : `unknown property "${key}".`,
1949
+ suggestion: suggestion ? `Did you mean "${suggestion}"?` : void 0
1950
+ });
1869
1951
  }
1870
1952
  for (const [field, fieldRule] of Object.entries(rule.required)) {
1871
1953
  if (!(field in obj) || obj[field] === void 0) {
@@ -3370,7 +3452,7 @@ async function createCDPClient(wsUrl, options = {}) {
3370
3452
  throw new Error("CDP client is not connected");
3371
3453
  }
3372
3454
  const id = ++messageId;
3373
- const effectiveSessionId = sessionId ?? currentSessionId;
3455
+ const effectiveSessionId = sessionId === null ? void 0 : sessionId ?? currentSessionId;
3374
3456
  const request = { id, method };
3375
3457
  if (params !== void 0) {
3376
3458
  request.params = params;
@@ -3849,6 +3931,285 @@ var RequestInterceptor = class {
3849
3931
  }
3850
3932
  };
3851
3933
 
3934
+ // src/browser/special-selectors.ts
3935
+ function stripQuotes(value) {
3936
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3937
+ return value.slice(1, -1);
3938
+ }
3939
+ return value;
3940
+ }
3941
+ function parseTextSelector(selector) {
3942
+ if (!selector.startsWith("text:")) return null;
3943
+ let raw = selector.slice(5).trim();
3944
+ let exact = false;
3945
+ if (raw.startsWith("=")) {
3946
+ exact = true;
3947
+ raw = raw.slice(1).trim();
3948
+ }
3949
+ const query = stripQuotes(raw);
3950
+ if (!query) return null;
3951
+ return { query, exact };
3952
+ }
3953
+ function parseRoleSelector(selector) {
3954
+ if (!selector.startsWith("role:")) return null;
3955
+ const body = selector.slice(5);
3956
+ const separator = body.indexOf(":");
3957
+ const role = (separator === -1 ? body : body.slice(0, separator)).trim().toLowerCase();
3958
+ const name = separator === -1 ? void 0 : stripQuotes(body.slice(separator + 1).trim());
3959
+ if (!role) return null;
3960
+ return { role, name: name || void 0 };
3961
+ }
3962
+ var SPECIAL_SELECTOR_SCRIPT = `
3963
+ function bpNormalizeSpace(value) {
3964
+ return String(value == null ? '' : value).replace(/\\s+/g, ' ').trim();
3965
+ }
3966
+
3967
+ function bpCollectElements(root) {
3968
+ var elements = [];
3969
+
3970
+ function visit(node) {
3971
+ if (!node || typeof node.querySelectorAll !== 'function') return;
3972
+ var matches = node.querySelectorAll('*');
3973
+ for (var i = 0; i < matches.length; i++) {
3974
+ var el = matches[i];
3975
+ elements.push(el);
3976
+ if (el.shadowRoot) {
3977
+ visit(el.shadowRoot);
3978
+ }
3979
+ }
3980
+ }
3981
+
3982
+ if (root && root.documentElement) {
3983
+ elements.push(root.documentElement);
3984
+ }
3985
+
3986
+ visit(root);
3987
+ return elements;
3988
+ }
3989
+
3990
+ function bpIsVisible(el) {
3991
+ if (!el) return false;
3992
+ var style = getComputedStyle(el);
3993
+ if (style.display === 'none') return false;
3994
+ if (style.visibility === 'hidden') return false;
3995
+ if (parseFloat(style.opacity || '1') === 0) return false;
3996
+ var rect = el.getBoundingClientRect();
3997
+ return rect.width > 0 && rect.height > 0;
3998
+ }
3999
+
4000
+ function bpInferRole(el) {
4001
+ if (!el || !el.tagName) return '';
4002
+
4003
+ var explicitRole = bpNormalizeSpace(el.getAttribute && el.getAttribute('role'));
4004
+ if (explicitRole) return explicitRole.toLowerCase();
4005
+
4006
+ var tag = el.tagName.toLowerCase();
4007
+ if (tag === 'button') return 'button';
4008
+ if (tag === 'a' && el.hasAttribute('href')) return 'link';
4009
+ if (tag === 'textarea') return 'textbox';
4010
+ if (tag === 'select') return el.multiple ? 'listbox' : 'combobox';
4011
+ if (tag === 'option') return 'option';
4012
+ if (tag === 'summary') return 'button';
4013
+
4014
+ if (tag === 'input') {
4015
+ var type = (el.type || 'text').toLowerCase();
4016
+ if (type === 'checkbox') return 'checkbox';
4017
+ if (type === 'radio') return 'radio';
4018
+ if (type === 'search') return 'searchbox';
4019
+ if (type === 'number') return 'spinbutton';
4020
+ if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') {
4021
+ return 'button';
4022
+ }
4023
+ return 'textbox';
4024
+ }
4025
+
4026
+ return '';
4027
+ }
4028
+
4029
+ function bpTextFromIdRefs(refs) {
4030
+ if (!refs) return '';
4031
+ var ids = refs.split(/\\s+/).filter(Boolean);
4032
+ var parts = [];
4033
+ for (var i = 0; i < ids.length; i++) {
4034
+ var node = document.getElementById(ids[i]);
4035
+ if (!node) continue;
4036
+ var text = bpNormalizeSpace(node.innerText || node.textContent || '');
4037
+ if (text) parts.push(text);
4038
+ }
4039
+ return bpNormalizeSpace(parts.join(' '));
4040
+ }
4041
+
4042
+ function bpAccessibleName(el) {
4043
+ if (!el) return '';
4044
+
4045
+ var labelledBy = bpTextFromIdRefs(el.getAttribute && el.getAttribute('aria-labelledby'));
4046
+ if (labelledBy) return labelledBy;
4047
+
4048
+ var ariaLabel = bpNormalizeSpace(el.getAttribute && el.getAttribute('aria-label'));
4049
+ if (ariaLabel) return ariaLabel;
4050
+
4051
+ if (el.labels && el.labels.length) {
4052
+ var labels = [];
4053
+ for (var i = 0; i < el.labels.length; i++) {
4054
+ var labelText = bpNormalizeSpace(el.labels[i].innerText || el.labels[i].textContent || '');
4055
+ if (labelText) labels.push(labelText);
4056
+ }
4057
+ if (labels.length) return bpNormalizeSpace(labels.join(' '));
4058
+ }
4059
+
4060
+ if (el.id) {
4061
+ var fallbackLabel = document.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]');
4062
+ if (fallbackLabel) {
4063
+ var fallbackText = bpNormalizeSpace(
4064
+ fallbackLabel.innerText || fallbackLabel.textContent || ''
4065
+ );
4066
+ if (fallbackText) return fallbackText;
4067
+ }
4068
+ }
4069
+
4070
+ var type = (el.type || '').toLowerCase();
4071
+ if (
4072
+ el.tagName === 'INPUT' &&
4073
+ (type === 'submit' || type === 'button' || type === 'reset' || type === 'image')
4074
+ ) {
4075
+ var inputValue = bpNormalizeSpace(el.value || el.getAttribute('value'));
4076
+ if (inputValue) return inputValue;
4077
+ }
4078
+
4079
+ var alt = bpNormalizeSpace(el.getAttribute && el.getAttribute('alt'));
4080
+ if (alt) return alt;
4081
+
4082
+ var text = bpNormalizeSpace(el.innerText || el.textContent || '');
4083
+ if (text) return text;
4084
+
4085
+ var placeholder = bpNormalizeSpace(el.getAttribute && el.getAttribute('placeholder'));
4086
+ if (placeholder) return placeholder;
4087
+
4088
+ var title = bpNormalizeSpace(el.getAttribute && el.getAttribute('title'));
4089
+ if (title) return title;
4090
+
4091
+ var value = bpNormalizeSpace(el.value);
4092
+ if (value) return value;
4093
+
4094
+ return bpNormalizeSpace(el.name || el.id || '');
4095
+ }
4096
+
4097
+ function bpIsInteractive(role, el) {
4098
+ if (
4099
+ role === 'button' ||
4100
+ role === 'link' ||
4101
+ role === 'textbox' ||
4102
+ role === 'checkbox' ||
4103
+ role === 'radio' ||
4104
+ role === 'combobox' ||
4105
+ role === 'listbox' ||
4106
+ role === 'option' ||
4107
+ role === 'searchbox' ||
4108
+ role === 'spinbutton' ||
4109
+ role === 'switch' ||
4110
+ role === 'tab'
4111
+ ) {
4112
+ return true;
4113
+ }
4114
+
4115
+ if (!el || !el.tagName) return false;
4116
+ var tag = el.tagName.toLowerCase();
4117
+ return tag === 'button' || tag === 'a' || tag === 'input' || tag === 'select' || tag === 'textarea';
4118
+ }
4119
+
4120
+ function bpFindByText(query, exact, includeHidden) {
4121
+ var needle = bpNormalizeSpace(query).toLowerCase();
4122
+ if (!needle) return null;
4123
+
4124
+ var best = null;
4125
+ var bestScore = -1;
4126
+ var elements = bpCollectElements(document);
4127
+
4128
+ for (var i = 0; i < elements.length; i++) {
4129
+ var el = elements[i];
4130
+ if (!includeHidden && !bpIsVisible(el)) continue;
4131
+
4132
+ var text = bpAccessibleName(el);
4133
+ if (!text) continue;
4134
+
4135
+ var haystack = text.toLowerCase();
4136
+ var matched = exact ? haystack === needle : haystack.includes(needle);
4137
+ if (!matched) continue;
4138
+
4139
+ var role = bpInferRole(el);
4140
+ var score = 0;
4141
+ if (bpIsInteractive(role, el)) score += 100;
4142
+ if (haystack === needle) score += 50;
4143
+ if (role === 'button' || role === 'link') score += 10;
4144
+
4145
+ if (score > bestScore) {
4146
+ best = el;
4147
+ bestScore = score;
4148
+ }
4149
+ }
4150
+
4151
+ return best;
4152
+ }
4153
+
4154
+ function bpFindByRole(role, name, includeHidden) {
4155
+ var targetRole = bpNormalizeSpace(role).toLowerCase();
4156
+ if (!targetRole) return null;
4157
+
4158
+ var nameNeedle = bpNormalizeSpace(name).toLowerCase();
4159
+ var best = null;
4160
+ var bestScore = -1;
4161
+ var elements = bpCollectElements(document);
4162
+
4163
+ for (var i = 0; i < elements.length; i++) {
4164
+ var el = elements[i];
4165
+ if (!includeHidden && !bpIsVisible(el)) continue;
4166
+
4167
+ var actualRole = bpInferRole(el);
4168
+ if (actualRole !== targetRole) continue;
4169
+
4170
+ var accessibleName = bpAccessibleName(el);
4171
+ if (nameNeedle) {
4172
+ var loweredName = accessibleName.toLowerCase();
4173
+ if (!loweredName.includes(nameNeedle)) continue;
4174
+ }
4175
+
4176
+ var score = 0;
4177
+ if (accessibleName) score += 10;
4178
+ if (nameNeedle && accessibleName.toLowerCase() === nameNeedle) score += 20;
4179
+
4180
+ if (score > bestScore) {
4181
+ best = el;
4182
+ bestScore = score;
4183
+ }
4184
+ }
4185
+
4186
+ return best;
4187
+ }
4188
+ `;
4189
+ function buildSpecialSelectorLookupExpression(selector, options = {}) {
4190
+ const includeHidden = options.includeHidden === true;
4191
+ const text = parseTextSelector(selector);
4192
+ if (text) {
4193
+ return `(() => {
4194
+ ${SPECIAL_SELECTOR_SCRIPT}
4195
+ return bpFindByText(${JSON.stringify(text.query)}, ${text.exact}, ${includeHidden});
4196
+ })()`;
4197
+ }
4198
+ const role = parseRoleSelector(selector);
4199
+ if (role) {
4200
+ return `(() => {
4201
+ ${SPECIAL_SELECTOR_SCRIPT}
4202
+ return bpFindByRole(${JSON.stringify(role.role)}, ${JSON.stringify(role.name ?? "")}, ${includeHidden});
4203
+ })()`;
4204
+ }
4205
+ return null;
4206
+ }
4207
+ function buildSpecialSelectorPredicateExpression(selector, options = {}) {
4208
+ const lookup = buildSpecialSelectorLookupExpression(selector, options);
4209
+ if (!lookup) return null;
4210
+ return `(() => !!(${lookup}))()`;
4211
+ }
4212
+
3852
4213
  // src/wait/strategies.ts
3853
4214
  var DEEP_QUERY_SCRIPT = `
3854
4215
  function deepQuery(selector, root = document) {
@@ -3882,18 +4243,19 @@ function deepQuery(selector, root = document) {
3882
4243
  }
3883
4244
  `;
3884
4245
  async function isElementVisible(cdp, selector, contextId) {
4246
+ const specialExpression = buildSpecialSelectorPredicateExpression(selector);
3885
4247
  const params = {
3886
- expression: `(() => {
3887
- ${DEEP_QUERY_SCRIPT}
3888
- const el = deepQuery(${JSON.stringify(selector)});
3889
- if (!el) return false;
3890
- const style = getComputedStyle(el);
3891
- if (style.display === 'none') return false;
3892
- if (style.visibility === 'hidden') return false;
3893
- if (parseFloat(style.opacity) === 0) return false;
3894
- const rect = el.getBoundingClientRect();
3895
- return rect.width > 0 && rect.height > 0;
3896
- })()`,
4248
+ expression: specialExpression ?? `(() => {
4249
+ ${DEEP_QUERY_SCRIPT}
4250
+ const el = deepQuery(${JSON.stringify(selector)});
4251
+ if (!el) return false;
4252
+ const style = getComputedStyle(el);
4253
+ if (style.display === 'none') return false;
4254
+ if (style.visibility === 'hidden') return false;
4255
+ if (parseFloat(style.opacity) === 0) return false;
4256
+ const rect = el.getBoundingClientRect();
4257
+ return rect.width > 0 && rect.height > 0;
4258
+ })()`,
3897
4259
  returnByValue: true
3898
4260
  };
3899
4261
  if (contextId !== void 0) {
@@ -3903,11 +4265,14 @@ async function isElementVisible(cdp, selector, contextId) {
3903
4265
  return result.result.value === true;
3904
4266
  }
3905
4267
  async function isElementAttached(cdp, selector, contextId) {
4268
+ const specialExpression = buildSpecialSelectorPredicateExpression(selector, {
4269
+ includeHidden: true
4270
+ });
3906
4271
  const params = {
3907
- expression: `(() => {
3908
- ${DEEP_QUERY_SCRIPT}
3909
- return deepQuery(${JSON.stringify(selector)}) !== null;
3910
- })()`,
4272
+ expression: specialExpression ?? `(() => {
4273
+ ${DEEP_QUERY_SCRIPT}
4274
+ return deepQuery(${JSON.stringify(selector)}) !== null;
4275
+ })()`,
3911
4276
  returnByValue: true
3912
4277
  };
3913
4278
  if (contextId !== void 0) {
@@ -4527,6 +4892,9 @@ var Page = class {
4527
4892
  timeout: options.timeout ?? DEFAULT_TIMEOUT2
4528
4893
  });
4529
4894
  } catch (e) {
4895
+ if (e instanceof ActionabilityError && e.failureType === "hitTarget" && await this.tryClickAssociatedLabel(objectId)) {
4896
+ return true;
4897
+ }
4530
4898
  if (options.optional) return false;
4531
4899
  throw e;
4532
4900
  }
@@ -4915,7 +5283,12 @@ var Page = class {
4915
5283
  returnByValue: true
4916
5284
  });
4917
5285
  if (!after.result.value) {
4918
- throw new Error("Clicking the checkbox did not change its state");
5286
+ if (await this.tryToggleViaLabel(object.objectId, true)) {
5287
+ return true;
5288
+ }
5289
+ throw new Error(
5290
+ "Clicking the checkbox did not change its state. Tried the associated label too."
5291
+ );
4919
5292
  }
4920
5293
  return true;
4921
5294
  });
@@ -4967,7 +5340,12 @@ var Page = class {
4967
5340
  returnByValue: true
4968
5341
  });
4969
5342
  if (after.result.value) {
4970
- throw new Error("Clicking the checkbox did not change its state");
5343
+ if (await this.tryToggleViaLabel(object.objectId, false)) {
5344
+ return true;
5345
+ }
5346
+ throw new Error(
5347
+ "Clicking the checkbox did not change its state. Tried the associated label too."
5348
+ );
4971
5349
  }
4972
5350
  return true;
4973
5351
  });
@@ -5291,7 +5669,7 @@ var Page = class {
5291
5669
  }
5292
5670
  const result = await this.cdp.send("Runtime.evaluate", params);
5293
5671
  if (result.exceptionDetails) {
5294
- throw new Error(`Evaluation failed: ${result.exceptionDetails.text}`);
5672
+ throw new Error(this.formatEvaluationError(result.exceptionDetails));
5295
5673
  }
5296
5674
  return result.result.value;
5297
5675
  }
@@ -5343,6 +5721,75 @@ var Page = class {
5343
5721
  return result.result.value ?? "";
5344
5722
  });
5345
5723
  }
5724
+ /**
5725
+ * Enumerate form controls on the page with labels and current state.
5726
+ */
5727
+ async forms() {
5728
+ const result = await this.evaluateInFrame(
5729
+ `(() => {
5730
+ function normalize(value) {
5731
+ return String(value == null ? '' : value).replace(/\\s+/g, ' ').trim();
5732
+ }
5733
+
5734
+ function labelFor(el) {
5735
+ if (!el) return '';
5736
+ if (el.labels && el.labels.length) {
5737
+ return normalize(
5738
+ Array.from(el.labels)
5739
+ .map((label) => label.innerText || label.textContent || '')
5740
+ .join(' ')
5741
+ );
5742
+ }
5743
+ var ariaLabel = normalize(el.getAttribute && el.getAttribute('aria-label'));
5744
+ if (ariaLabel) return ariaLabel;
5745
+ if (el.id) {
5746
+ var byFor = document.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]');
5747
+ if (byFor) return normalize(byFor.innerText || byFor.textContent || '');
5748
+ }
5749
+ var closest = el.closest && el.closest('label');
5750
+ if (closest) return normalize(closest.innerText || closest.textContent || '');
5751
+ return '';
5752
+ }
5753
+
5754
+ return Array.from(document.querySelectorAll('input, select, textarea')).map((el) => {
5755
+ var tag = el.tagName.toLowerCase();
5756
+ var type = tag === 'input' ? (el.type || 'text').toLowerCase() : tag;
5757
+ var value = null;
5758
+
5759
+ if (tag === 'select') {
5760
+ value = el.multiple
5761
+ ? Array.from(el.selectedOptions).map((opt) => opt.value)
5762
+ : el.value || null;
5763
+ } else if (tag === 'textarea' || tag === 'input') {
5764
+ value = typeof el.value === 'string' ? el.value : null;
5765
+ }
5766
+
5767
+ return {
5768
+ tag: tag,
5769
+ type: type,
5770
+ id: el.id || undefined,
5771
+ name: el.getAttribute('name') || undefined,
5772
+ value: value,
5773
+ checked: 'checked' in el ? !!el.checked : undefined,
5774
+ required: !!el.required,
5775
+ disabled: !!el.disabled,
5776
+ label: labelFor(el) || undefined,
5777
+ placeholder: normalize(el.getAttribute && el.getAttribute('placeholder')) || undefined,
5778
+ options:
5779
+ tag === 'select'
5780
+ ? Array.from(el.options).map((opt) => ({
5781
+ value: opt.value || '',
5782
+ text: normalize(opt.text || opt.label || ''),
5783
+ selected: !!opt.selected,
5784
+ disabled: !!opt.disabled,
5785
+ }))
5786
+ : undefined,
5787
+ };
5788
+ });
5789
+ })()`
5790
+ );
5791
+ return result.result.value ?? [];
5792
+ }
5346
5793
  // ============ File Handling ============
5347
5794
  /**
5348
5795
  * Set files on a file input
@@ -5815,7 +6262,8 @@ var Page = class {
5815
6262
  /**
5816
6263
  * Get an accessibility tree snapshot of the page
5817
6264
  */
5818
- async snapshot() {
6265
+ async snapshot(options = {}) {
6266
+ const roleFilter = new Set((options.roles ?? []).map((role) => role.trim().toLowerCase()));
5819
6267
  const [url, title, axTree] = await Promise.all([
5820
6268
  this.url(),
5821
6269
  this.title(),
@@ -5836,7 +6284,7 @@ var Page = class {
5836
6284
  const buildNode = (nodeId) => {
5837
6285
  const node = nodeMap.get(nodeId);
5838
6286
  if (!node) return null;
5839
- const role = node.role?.value ?? "generic";
6287
+ const role = (node.role?.value ?? "generic").toLowerCase();
5840
6288
  const name = node.name?.value;
5841
6289
  const value = node.value?.value;
5842
6290
  const ref = nodeRefs.get(nodeId);
@@ -5852,7 +6300,7 @@ var Page = class {
5852
6300
  return {
5853
6301
  role,
5854
6302
  name,
5855
- value,
6303
+ value: value !== void 0 ? String(value) : void 0,
5856
6304
  ref,
5857
6305
  children: children.length > 0 ? children : void 0,
5858
6306
  disabled,
@@ -5860,7 +6308,24 @@ var Page = class {
5860
6308
  };
5861
6309
  };
5862
6310
  const rootNodes = nodes.filter((n) => !n.parentId || !nodeMap.has(n.parentId));
5863
- const accessibilityTree = rootNodes.map((n) => buildNode(n.nodeId)).filter((n) => n !== null);
6311
+ let accessibilityTree = rootNodes.map((n) => buildNode(n.nodeId)).filter((n) => n !== null);
6312
+ if (roleFilter.size > 0) {
6313
+ const filteredAccessibilityTree = [];
6314
+ for (const node of nodes) {
6315
+ if (!roleFilter.has((node.role?.value ?? "generic").toLowerCase())) {
6316
+ continue;
6317
+ }
6318
+ const snapshotNode = buildNode(node.nodeId);
6319
+ if (!snapshotNode) {
6320
+ continue;
6321
+ }
6322
+ filteredAccessibilityTree.push({
6323
+ ...snapshotNode,
6324
+ children: void 0
6325
+ });
6326
+ }
6327
+ accessibilityTree = filteredAccessibilityTree;
6328
+ }
5864
6329
  const interactiveRoles = /* @__PURE__ */ new Set([
5865
6330
  "button",
5866
6331
  "link",
@@ -5882,37 +6347,44 @@ var Page = class {
5882
6347
  ]);
5883
6348
  const interactiveElements = [];
5884
6349
  for (const node of nodes) {
5885
- const role = node.role?.value;
5886
- if (role && interactiveRoles.has(role)) {
6350
+ const role = (node.role?.value ?? "").toLowerCase();
6351
+ if (role && interactiveRoles.has(role) && (roleFilter.size === 0 || roleFilter.has(role))) {
5887
6352
  const ref = nodeRefs.get(node.nodeId);
5888
6353
  const name = node.name?.value ?? "";
5889
6354
  const disabled = node.properties?.find((p) => p.name === "disabled")?.value.value;
6355
+ const checked = node.properties?.find((p) => p.name === "checked")?.value.value;
6356
+ const value = node.value?.value;
5890
6357
  const selector = node.backendDOMNodeId ? `[data-backend-node-id="${node.backendDOMNodeId}"]` : `[aria-label="${name}"]`;
5891
6358
  interactiveElements.push({
5892
6359
  ref,
5893
6360
  role,
5894
6361
  name,
5895
6362
  selector,
5896
- disabled
6363
+ disabled,
6364
+ checked,
6365
+ value: value !== void 0 ? String(value) : void 0
5897
6366
  });
5898
6367
  }
5899
6368
  }
6369
+ const formatNode = (node, depth = 0) => {
6370
+ let line = `${" ".repeat(depth)}- ${node.role}`;
6371
+ if (node.name) line += ` "${node.name}"`;
6372
+ line += ` ref:${node.ref}`;
6373
+ if (node.disabled) line += " (disabled)";
6374
+ if (node.checked !== void 0) line += node.checked ? " (checked)" : " (unchecked)";
6375
+ return line;
6376
+ };
5900
6377
  const formatTree = (nodes2, depth = 0) => {
5901
6378
  const lines = [];
5902
6379
  for (const node of nodes2) {
5903
- let line = `${" ".repeat(depth)}- ${node.role}`;
5904
- if (node.name) line += ` "${node.name}"`;
5905
- line += ` [ref=${node.ref}]`;
5906
- if (node.disabled) line += " (disabled)";
5907
- if (node.checked !== void 0) line += node.checked ? " (checked)" : " (unchecked)";
5908
- lines.push(line);
6380
+ lines.push(formatNode(node, depth));
5909
6381
  if (node.children) {
5910
6382
  lines.push(formatTree(node.children, depth + 1));
5911
6383
  }
5912
6384
  }
5913
6385
  return lines.join("\n");
5914
6386
  };
5915
- const text = formatTree(accessibilityTree);
6387
+ const text = roleFilter.size > 0 ? accessibilityTree.map((node) => formatNode(node)).join("\n") : formatTree(accessibilityTree);
5916
6388
  const result = {
5917
6389
  url,
5918
6390
  title,
@@ -5921,7 +6393,9 @@ var Page = class {
5921
6393
  interactiveElements,
5922
6394
  text
5923
6395
  };
5924
- this.lastSnapshot = result;
6396
+ if (roleFilter.size === 0) {
6397
+ this.lastSnapshot = result;
6398
+ }
5925
6399
  return result;
5926
6400
  }
5927
6401
  /**
@@ -6476,7 +6950,7 @@ var Page = class {
6476
6950
  }
6477
6951
  /**
6478
6952
  * Find an element using single or multiple selectors
6479
- * Supports ref: prefix for ref-based selectors (e.g., "ref:e4")
6953
+ * Supports ref:, text:, and role: selectors.
6480
6954
  */
6481
6955
  async findElement(selectors, options = {}) {
6482
6956
  const { timeout = DEFAULT_TIMEOUT2 } = options;
@@ -6543,11 +7017,11 @@ var Page = class {
6543
7017
  }
6544
7018
  }
6545
7019
  }
6546
- const cssSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
6547
- if (cssSelectors.length === 0) {
7020
+ const runtimeSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
7021
+ if (runtimeSelectors.length === 0) {
6548
7022
  return null;
6549
7023
  }
6550
- const result = await waitForAnyElement(this.cdp, cssSelectors, {
7024
+ const result = await waitForAnyElement(this.cdp, runtimeSelectors, {
6551
7025
  state: "visible",
6552
7026
  timeout,
6553
7027
  contextId: this.currentFrameContextId ?? void 0
@@ -6555,6 +7029,14 @@ var Page = class {
6555
7029
  if (!result.success || !result.selector) {
6556
7030
  return null;
6557
7031
  }
7032
+ const specialSelectorMatch = await this.resolveSpecialSelector(result.selector);
7033
+ if (specialSelectorMatch) {
7034
+ this._lastMatchedSelector = result.selector;
7035
+ return {
7036
+ ...specialSelectorMatch,
7037
+ waitedMs: result.waitedMs
7038
+ };
7039
+ }
6558
7040
  await this.ensureRootNode();
6559
7041
  const queryResult = await this.cdp.send("DOM.querySelector", {
6560
7042
  nodeId: this.rootNodeId,
@@ -6601,6 +7083,122 @@ var Page = class {
6601
7083
  waitedMs: result.waitedMs
6602
7084
  };
6603
7085
  }
7086
+ formatEvaluationError(details) {
7087
+ const description = typeof details.exception?.description === "string" && details.exception.description || typeof details.exception?.value === "string" && details.exception.value || details.text || "Uncaught";
7088
+ return `Evaluation failed: ${description}`;
7089
+ }
7090
+ async resolveSpecialSelector(selector, options = {}) {
7091
+ const expression = buildSpecialSelectorLookupExpression(selector, options);
7092
+ if (!expression) return null;
7093
+ const result = await this.evaluateInFrame(expression, {
7094
+ returnByValue: false
7095
+ });
7096
+ if (!result.result.objectId) {
7097
+ return null;
7098
+ }
7099
+ const resolved = await this.objectIdToNode(result.result.objectId);
7100
+ if (!resolved) {
7101
+ return null;
7102
+ }
7103
+ return {
7104
+ nodeId: resolved.nodeId,
7105
+ backendNodeId: resolved.backendNodeId,
7106
+ selector,
7107
+ waitedMs: 0
7108
+ };
7109
+ }
7110
+ async readCheckedState(objectId) {
7111
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
7112
+ objectId,
7113
+ functionDeclaration: "function() { return !!this.checked; }",
7114
+ returnByValue: true
7115
+ });
7116
+ return result.result.value === true;
7117
+ }
7118
+ async readInputType(objectId) {
7119
+ const result = await this.cdp.send(
7120
+ "Runtime.callFunctionOn",
7121
+ {
7122
+ objectId,
7123
+ functionDeclaration: 'function() { return this instanceof HTMLInputElement ? String(this.type || "").toLowerCase() : null; }',
7124
+ returnByValue: true
7125
+ }
7126
+ );
7127
+ return result.result.value ?? null;
7128
+ }
7129
+ async getAssociatedLabelNodeId(objectId) {
7130
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
7131
+ objectId,
7132
+ functionDeclaration: `function() {
7133
+ if (!(this instanceof HTMLInputElement)) return null;
7134
+
7135
+ if (this.id) {
7136
+ var labels = Array.from(document.querySelectorAll('label'));
7137
+ for (var i = 0; i < labels.length; i++) {
7138
+ if (labels[i].htmlFor === this.id) return labels[i];
7139
+ }
7140
+ }
7141
+
7142
+ return this.closest('label');
7143
+ }`,
7144
+ returnByValue: false
7145
+ });
7146
+ if (!result.result.objectId) {
7147
+ return null;
7148
+ }
7149
+ return (await this.objectIdToNode(result.result.objectId))?.nodeId ?? null;
7150
+ }
7151
+ async objectIdToNode(objectId) {
7152
+ const describeResult = await this.cdp.send("DOM.describeNode", {
7153
+ objectId,
7154
+ depth: 0
7155
+ });
7156
+ const backendNodeId = describeResult.node.backendNodeId;
7157
+ if (!backendNodeId) {
7158
+ return null;
7159
+ }
7160
+ if (describeResult.node.nodeId) {
7161
+ return {
7162
+ nodeId: describeResult.node.nodeId,
7163
+ backendNodeId
7164
+ };
7165
+ }
7166
+ await this.ensureRootNode();
7167
+ const pushResult = await this.cdp.send(
7168
+ "DOM.pushNodesByBackendIdsToFrontend",
7169
+ {
7170
+ backendNodeIds: [backendNodeId]
7171
+ }
7172
+ );
7173
+ const nodeId = pushResult.nodeIds?.[0];
7174
+ if (!nodeId) {
7175
+ return null;
7176
+ }
7177
+ return { nodeId, backendNodeId };
7178
+ }
7179
+ async tryClickAssociatedLabel(objectId) {
7180
+ const inputType = await this.readInputType(objectId);
7181
+ if (inputType !== "checkbox" && inputType !== "radio") {
7182
+ return false;
7183
+ }
7184
+ const labelNodeId = await this.getAssociatedLabelNodeId(objectId);
7185
+ if (!labelNodeId) {
7186
+ return false;
7187
+ }
7188
+ try {
7189
+ await this.scrollIntoView(labelNodeId);
7190
+ await this.clickElement(labelNodeId);
7191
+ return true;
7192
+ } catch {
7193
+ return false;
7194
+ }
7195
+ }
7196
+ async tryToggleViaLabel(objectId, desiredChecked) {
7197
+ if (!await this.tryClickAssociatedLabel(objectId)) {
7198
+ return false;
7199
+ }
7200
+ return await this.readCheckedState(objectId) === desiredChecked;
7201
+ }
6604
7202
  /**
6605
7203
  * Ensure we have a valid root node ID
6606
7204
  */
@@ -7018,6 +7616,7 @@ var Browser = class _Browser {
7018
7616
  cdp;
7019
7617
  providerSession;
7020
7618
  pages = /* @__PURE__ */ new Map();
7619
+ pageCounter = 0;
7021
7620
  constructor(cdp, _provider, providerSession, _options) {
7022
7621
  this.cdp = cdp;
7023
7622
  this.providerSession = providerSession;
@@ -7047,7 +7646,11 @@ var Browser = class _Browser {
7047
7646
  const pageName = name ?? "default";
7048
7647
  const cached = this.pages.get(pageName);
7049
7648
  if (cached) return cached;
7050
- const targets = await this.cdp.send("Target.getTargets");
7649
+ const targets = await this.cdp.send(
7650
+ "Target.getTargets",
7651
+ void 0,
7652
+ null
7653
+ );
7051
7654
  let pageTargets = targets.targetInfos.filter((t) => t.type === "page");
7052
7655
  if (options?.targetUrl) {
7053
7656
  const urlFilter = options.targetUrl;
@@ -7069,16 +7672,24 @@ var Browser = class _Browser {
7069
7672
  targetId = options.targetId;
7070
7673
  } else {
7071
7674
  console.warn(`[browser-pilot] Target ${options.targetId} no longer exists, falling back`);
7072
- targetId = pickBestTarget(pageTargets) ?? (await this.cdp.send("Target.createTarget", {
7073
- url: "about:blank"
7074
- })).targetId;
7675
+ targetId = pickBestTarget(pageTargets) ?? (await this.cdp.send(
7676
+ "Target.createTarget",
7677
+ {
7678
+ url: "about:blank"
7679
+ },
7680
+ null
7681
+ )).targetId;
7075
7682
  }
7076
7683
  } else if (pageTargets.length > 0) {
7077
7684
  targetId = pickBestTarget(pageTargets);
7078
7685
  } else {
7079
- const result = await this.cdp.send("Target.createTarget", {
7080
- url: "about:blank"
7081
- });
7686
+ const result = await this.cdp.send(
7687
+ "Target.createTarget",
7688
+ {
7689
+ url: "about:blank"
7690
+ },
7691
+ null
7692
+ );
7082
7693
  targetId = result.targetId;
7083
7694
  }
7084
7695
  await this.cdp.attachToTarget(targetId);
@@ -7106,13 +7717,17 @@ var Browser = class _Browser {
7106
7717
  * Create a new page (tab)
7107
7718
  */
7108
7719
  async newPage(url = "about:blank") {
7109
- const result = await this.cdp.send("Target.createTarget", {
7110
- url
7111
- });
7720
+ const result = await this.cdp.send(
7721
+ "Target.createTarget",
7722
+ {
7723
+ url
7724
+ },
7725
+ null
7726
+ );
7112
7727
  await this.cdp.attachToTarget(result.targetId);
7113
7728
  const page = new Page(this.cdp, result.targetId);
7114
7729
  await page.init();
7115
- const name = `page-${this.pages.size + 1}`;
7730
+ const name = `page-${++this.pageCounter}`;
7116
7731
  this.pages.set(name, page);
7117
7732
  return page;
7118
7733
  }
@@ -7122,14 +7737,30 @@ var Browser = class _Browser {
7122
7737
  async closePage(name) {
7123
7738
  const page = this.pages.get(name);
7124
7739
  if (!page) return;
7125
- const targets = await this.cdp.send("Target.getTargets");
7126
- const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
7127
- if (pageTargets.length > 0) {
7128
- await this.cdp.send("Target.closeTarget", {
7129
- targetId: pageTargets[0].targetId
7130
- });
7131
- }
7740
+ const targetId = page.targetId;
7741
+ await this.cdp.send("Target.closeTarget", { targetId }, null);
7132
7742
  this.pages.delete(name);
7743
+ const deadline = Date.now() + 5e3;
7744
+ while (Date.now() < deadline) {
7745
+ const { targetInfos } = await this.cdp.send(
7746
+ "Target.getTargets",
7747
+ void 0,
7748
+ null
7749
+ );
7750
+ if (!targetInfos.some((t) => t.targetId === targetId)) return;
7751
+ await new Promise((r) => setTimeout(r, 50));
7752
+ }
7753
+ }
7754
+ /**
7755
+ * List all page targets in the connected browser.
7756
+ */
7757
+ async listTargets() {
7758
+ const { targetInfos } = await this.cdp.send(
7759
+ "Target.getTargets",
7760
+ void 0,
7761
+ null
7762
+ );
7763
+ return targetInfos.filter((target) => target.type === "page");
7133
7764
  }
7134
7765
  /**
7135
7766
  * Get the WebSocket URL for this browser connection
@@ -8039,9 +8670,12 @@ Usage:
8039
8670
 
8040
8671
  Options:
8041
8672
  -p, --provider <type> Provider: generic | browserbase | browserless (default: generic)
8042
- --url <ws-url> WebSocket URL (auto-discovered for generic provider)
8673
+ --url <value> Browser WebSocket URL, or page URL when used with --new-tab
8674
+ --browser-url <ws-url> Explicit browser WebSocket URL
8675
+ --page-url <url> URL to open in the attached page/new tab
8043
8676
  -n, --name <id> Custom session name (default: auto-generated)
8044
8677
  -r, --resume <id> Resume an existing session by ID
8678
+ --new-tab Create and attach to a fresh tab instead of reusing an existing one
8045
8679
  --target-url <str> Filter targets to those whose URL contains this string
8046
8680
  --api-key <key> API key for cloud providers
8047
8681
  --project-id <id> Project ID for BrowserBase provider
@@ -8056,6 +8690,7 @@ Examples:
8056
8690
  bp connect --url ws://localhost:9222/devtools # Explicit WebSocket URL
8057
8691
  bp connect --resume dev # Resume a previous session
8058
8692
  bp connect --target-url localhost:3000 # Attach to tab matching URL
8693
+ bp connect --new-tab --url https://example.com # Create and attach to a fresh tab
8059
8694
  `.trimEnd();
8060
8695
  function parseConnectArgs(args) {
8061
8696
  const options = {};
@@ -8065,10 +8700,16 @@ function parseConnectArgs(args) {
8065
8700
  options.provider = args[++i];
8066
8701
  } else if (arg === "--url") {
8067
8702
  options.url = args[++i];
8703
+ } else if (arg === "--browser-url") {
8704
+ options.browserUrl = args[++i];
8705
+ } else if (arg === "--page-url") {
8706
+ options.pageUrl = args[++i];
8068
8707
  } else if (arg === "--name" || arg === "-n") {
8069
8708
  options.name = args[++i];
8070
8709
  } else if (arg === "--resume" || arg === "-r") {
8071
8710
  options.resume = args[++i];
8711
+ } else if (arg === "--new-tab") {
8712
+ options.newTab = true;
8072
8713
  } else if (arg === "--target-url") {
8073
8714
  options.targetUrl = args[++i];
8074
8715
  } else if (arg === "--api-key") {
@@ -8103,7 +8744,14 @@ async function connectCommand(args, globalOptions) {
8103
8744
  return;
8104
8745
  }
8105
8746
  const provider = options.provider ?? "generic";
8106
- let wsUrl = options.url;
8747
+ let wsUrl = options.browserUrl ?? options.url;
8748
+ let pageUrl = options.pageUrl;
8749
+ if (options.newTab && options.url && !options.url.startsWith("ws://") && !options.url.startsWith("wss://")) {
8750
+ pageUrl = options.url;
8751
+ if (!options.browserUrl) {
8752
+ wsUrl = void 0;
8753
+ }
8754
+ }
8107
8755
  if (provider === "generic" && !wsUrl) {
8108
8756
  try {
8109
8757
  wsUrl = await getBrowserWebSocketUrl("localhost:9222");
@@ -8121,8 +8769,10 @@ async function connectCommand(args, globalOptions) {
8121
8769
  projectId: options.projectId
8122
8770
  };
8123
8771
  const browser = await connect(connectOptions);
8124
- const pageOptions = options.targetUrl ? { targetUrl: options.targetUrl } : void 0;
8125
- const page = await browser.page(void 0, pageOptions);
8772
+ const page = options.newTab ? await browser.newPage(pageUrl ?? "about:blank") : await browser.page(
8773
+ void 0,
8774
+ options.targetUrl ? { targetUrl: options.targetUrl } : void 0
8775
+ );
8126
8776
  const currentUrl = await page.url();
8127
8777
  const sessionId = options.name ?? generateSessionId();
8128
8778
  const session = {
@@ -8835,6 +9485,7 @@ Usage:
8835
9485
 
8836
9486
  Options:
8837
9487
  -f, --file <path> Read JavaScript from a file
9488
+ --wrap Wrap the expression in an async IIFE
8838
9489
  -s, --session <id> Session to use (default: most recent)
8839
9490
  -f, --format <fmt> Output format: json | pretty (default: pretty)
8840
9491
  --json Alias for -f json
@@ -8853,12 +9504,25 @@ function parseEvalArgs(args) {
8853
9504
  const arg = args[i];
8854
9505
  if (arg === "-f" || arg === "--file") {
8855
9506
  options.file = args[++i];
9507
+ } else if (arg === "--wrap") {
9508
+ options.wrap = true;
8856
9509
  } else if (!expression && !arg.startsWith("-")) {
8857
9510
  expression = arg;
8858
9511
  }
8859
9512
  }
8860
9513
  return { expression, options };
8861
9514
  }
9515
+ function normalizeEvalExpression(expression, wrap = false) {
9516
+ const trimmed = expression.trim();
9517
+ const needsWrap = wrap || trimmed.includes("=>") || /\bawait\b/.test(trimmed);
9518
+ if (!needsWrap) {
9519
+ return trimmed;
9520
+ }
9521
+ if (wrap || /\bawait\b/.test(trimmed)) {
9522
+ return `(async () => (${trimmed}))()`;
9523
+ }
9524
+ return `(() => (${trimmed}))()`;
9525
+ }
8862
9526
  async function evalCommand(args, globalOptions) {
8863
9527
  if (globalOptions.help) {
8864
9528
  console.log(EVAL_HELP);
@@ -8885,7 +9549,10 @@ async function evalCommand(args, globalOptions) {
8885
9549
  const session = await resolveSession(globalOptions.session);
8886
9550
  const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
8887
9551
  try {
8888
- const step = { action: "evaluate", value: expression };
9552
+ const step = {
9553
+ action: "evaluate",
9554
+ value: normalizeEvalExpression(expression, evalOptions.wrap)
9555
+ };
8889
9556
  const result = await page.batch([step]);
8890
9557
  const stepResult = result.steps[0];
8891
9558
  if (!stepResult.success) {
@@ -9079,6 +9746,7 @@ Usage:
9079
9746
 
9080
9747
  Options:
9081
9748
  -f, --file <path> Read actions from a JSON file
9749
+ -o, --output <path> Write command output to a file instead of stdout
9082
9750
  --dialog <mode> Handle native dialogs: accept | dismiss
9083
9751
  -s, --session <id> Session to use (default: most recent)
9084
9752
  -f, --format <fmt> Output format: json | pretty (default: pretty)
@@ -9109,12 +9777,33 @@ function parseExecArgs(args) {
9109
9777
  }
9110
9778
  } else if (arg === "-f" || arg === "--file") {
9111
9779
  options.file = args[++i];
9780
+ } else if (arg === "-o" || arg === "--output") {
9781
+ options.outputFile = args[++i];
9112
9782
  } else if (!actionsJson && !arg.startsWith("-")) {
9113
9783
  actionsJson = arg;
9114
9784
  }
9115
9785
  }
9116
9786
  return { actionsJson, options };
9117
9787
  }
9788
+ async function getCurrentUrlSafe(page, fallback) {
9789
+ try {
9790
+ return await page.url();
9791
+ } catch {
9792
+ return fallback;
9793
+ }
9794
+ }
9795
+ async function captureFinalUrl(page, steps, fallback) {
9796
+ const currentUrl = await getCurrentUrlSafe(page, fallback);
9797
+ if (currentUrl !== fallback) {
9798
+ return currentUrl;
9799
+ }
9800
+ const mightNavigate = steps.some((step) => step.action === "click" || step.action === "submit");
9801
+ if (!mightNavigate) {
9802
+ return currentUrl;
9803
+ }
9804
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
9805
+ return getCurrentUrlSafe(page, currentUrl);
9806
+ }
9118
9807
  async function execCommand(args, globalOptions) {
9119
9808
  if (globalOptions.help) {
9120
9809
  console.log(EXEC_HELP);
@@ -9173,8 +9862,12 @@ Run 'bp actions' for complete action reference.${evalTip}`
9173
9862
  }
9174
9863
  const steps = Array.isArray(actions) ? actions : [actions];
9175
9864
  const urlBefore = await page.url();
9865
+ const currentTargetId = page.targetId;
9866
+ const closesCurrentTarget = steps.some(
9867
+ (step) => step.action === "closeTab" && (!step.targetId || step.targetId === currentTargetId)
9868
+ );
9176
9869
  const result = await page.batch(steps);
9177
- const urlAfter = await page.url();
9870
+ const urlAfter = closesCurrentTarget ? urlBefore : await captureFinalUrl(page, steps, urlBefore);
9178
9871
  for (const stepResult of result.steps) {
9179
9872
  logger.logCommand(
9180
9873
  stepResult.action,
@@ -9196,9 +9889,14 @@ Run 'bp actions' for complete action reference.${evalTip}`
9196
9889
  urlBefore,
9197
9890
  urlAfter
9198
9891
  });
9199
- const currentUrl = await page.url();
9892
+ const currentUrl = closesCurrentTarget ? urlBefore : await captureFinalUrl(page, steps, urlAfter);
9200
9893
  const hasSnapshot = steps.some((step) => step.action === "snapshot");
9201
- if (hasSnapshot) {
9894
+ if (closesCurrentTarget) {
9895
+ await updateSession(session.id, {
9896
+ currentUrl,
9897
+ targetId: void 0
9898
+ });
9899
+ } else if (hasSnapshot) {
9202
9900
  await updateSession(session.id, {
9203
9901
  currentUrl,
9204
9902
  metadata: {
@@ -9221,16 +9919,21 @@ Run 'bp actions' for complete action reference.${evalTip}`
9221
9919
  text: s.text,
9222
9920
  result: s.result
9223
9921
  }));
9224
- output(
9225
- {
9226
- success: result.success,
9227
- stoppedAtIndex: result.stoppedAtIndex,
9228
- steps: outputSteps,
9229
- totalDurationMs: result.totalDurationMs,
9230
- currentUrl
9231
- },
9232
- globalOptions.format
9233
- );
9922
+ const payload = {
9923
+ success: result.success,
9924
+ stoppedAtIndex: result.stoppedAtIndex,
9925
+ steps: outputSteps,
9926
+ totalDurationMs: result.totalDurationMs,
9927
+ currentUrl
9928
+ };
9929
+ if (execOptions.outputFile) {
9930
+ const fs3 = await import("fs/promises");
9931
+ await fs3.writeFile(execOptions.outputFile, renderOutput(payload, globalOptions.format));
9932
+ process.stderr.write(`Wrote output to ${execOptions.outputFile}
9933
+ `);
9934
+ } else {
9935
+ output(payload, globalOptions.format);
9936
+ }
9234
9937
  const failedEval = result.steps.find((s) => s.action === "evaluate" && !s.success);
9235
9938
  if (failedEval) {
9236
9939
  console.error(
@@ -9243,6 +9946,116 @@ Tip: Use "bp eval 'expression'" for simpler JavaScript inspection/debugging (no
9243
9946
  }
9244
9947
  }
9245
9948
 
9949
+ // src/cli/commands/form-utils.ts
9950
+ function fieldIdentifier(field) {
9951
+ if (field.id) return `#${field.id}`;
9952
+ if (field.name) return `name=${field.name}`;
9953
+ return `<${field.tag}>`;
9954
+ }
9955
+ function fieldState(field) {
9956
+ if (field.type === "checkbox" || field.type === "radio") {
9957
+ return field.checked ? "checked" : "unchecked";
9958
+ }
9959
+ if (Array.isArray(field.value)) {
9960
+ return field.value.length > 0 ? field.value.join(", ") : '""';
9961
+ }
9962
+ if (typeof field.value === "string") {
9963
+ return JSON.stringify(field.value);
9964
+ }
9965
+ return '""';
9966
+ }
9967
+ function fieldMeta(field) {
9968
+ const bits = [field.label];
9969
+ if (field.required) bits.push("required");
9970
+ if (field.disabled) bits.push("disabled");
9971
+ return bits.filter(Boolean).join(" | ");
9972
+ }
9973
+ function formatFormFieldsPretty(fields) {
9974
+ const lines = [];
9975
+ const seenRadioGroups = /* @__PURE__ */ new Set();
9976
+ for (const field of fields) {
9977
+ if (field.type === "radio" && field.name) {
9978
+ if (seenRadioGroups.has(field.name)) continue;
9979
+ seenRadioGroups.add(field.name);
9980
+ const group = fields.filter(
9981
+ (candidate) => candidate.type === "radio" && candidate.name === field.name
9982
+ );
9983
+ const options = group.map((candidate) => {
9984
+ const label = candidate.label || candidate.value || candidate.id || "(unnamed)";
9985
+ return candidate.checked ? `${label} [checked]` : label;
9986
+ }).join(" | ");
9987
+ const meta2 = fieldMeta(group.find((candidate) => candidate.label) ?? field);
9988
+ lines.push(` ${fieldIdentifier(field)} radio ${options}${meta2 ? ` ${meta2}` : ""}`);
9989
+ continue;
9990
+ }
9991
+ let state = fieldState(field);
9992
+ if (field.options?.length) {
9993
+ const options = field.options.map((option) => option.selected ? `${option.text} [selected]` : option.text).join(" | ");
9994
+ state += options ? ` ${options}` : "";
9995
+ }
9996
+ const meta = fieldMeta(field);
9997
+ lines.push(` ${fieldIdentifier(field)} ${field.type} ${state}${meta ? ` ${meta}` : ""}`);
9998
+ }
9999
+ return lines;
10000
+ }
10001
+ function formatInteractiveElementsPretty(elements, limit = elements.length) {
10002
+ return elements.slice(0, limit).map((element) => {
10003
+ let line = ` ref:${element.ref} ${element.role}`;
10004
+ if (element.name) {
10005
+ line += ` "${element.name}"`;
10006
+ }
10007
+ if (element.disabled) {
10008
+ line += " (disabled)";
10009
+ }
10010
+ if (element.checked !== void 0) {
10011
+ line += element.checked ? " (checked)" : " (unchecked)";
10012
+ }
10013
+ return line;
10014
+ });
10015
+ }
10016
+
10017
+ // src/cli/commands/forms.ts
10018
+ var FORMS_HELP = `
10019
+ bp forms - List form controls on the current page
10020
+
10021
+ Usage:
10022
+ bp forms [options]
10023
+
10024
+ Options:
10025
+ -s, --session <id> Session to use (default: most recent)
10026
+ -f, --format <fmt> json | pretty (default: pretty)
10027
+ --json Alias for -f json
10028
+ --trace Enable debug tracing
10029
+ -h, --help Show this help
10030
+
10031
+ Examples:
10032
+ bp forms
10033
+ bp forms --json
10034
+ `.trimEnd();
10035
+ async function formsCommand(_args, globalOptions) {
10036
+ if (globalOptions.help) {
10037
+ process.stdout.write(`${FORMS_HELP}
10038
+ `);
10039
+ return;
10040
+ }
10041
+ const session = await resolveSession(globalOptions.session);
10042
+ const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
10043
+ try {
10044
+ const [forms, currentUrl] = await Promise.all([page.forms(), page.url()]);
10045
+ if (globalOptions.format === "json") {
10046
+ output(forms, "json");
10047
+ } else if (forms.length === 0) {
10048
+ process.stdout.write("No form controls found.\n");
10049
+ } else {
10050
+ process.stdout.write(`${formatFormFieldsPretty(forms).join("\n")}
10051
+ `);
10052
+ }
10053
+ await updateSession(session.id, { currentUrl });
10054
+ } finally {
10055
+ await browser.disconnect();
10056
+ }
10057
+ }
10058
+
9246
10059
  // src/cli/commands/list.ts
9247
10060
  var HELP = `
9248
10061
  bp list - List sessions and view action logs
@@ -9781,6 +10594,91 @@ Timeout reached (${options.timeout}ms).`);
9781
10594
  }
9782
10595
  }
9783
10596
 
10597
+ // src/cli/commands/page.ts
10598
+ var PAGE_HELP = `
10599
+ bp page - Show a compact overview of the current page
10600
+
10601
+ Usage:
10602
+ bp page [options]
10603
+
10604
+ Options:
10605
+ -s, --session <id> Session to use (default: most recent)
10606
+ -f, --format <fmt> json | pretty (default: pretty)
10607
+ --json Alias for -f json
10608
+ --trace Enable debug tracing
10609
+ -h, --help Show this help
10610
+
10611
+ Examples:
10612
+ bp page
10613
+ bp page --json
10614
+ `.trimEnd();
10615
+ async function getHeadings(page) {
10616
+ return page.evaluate(`(() => {
10617
+ return Array.from(document.querySelectorAll('h1, h2, h3'))
10618
+ .map((el) => ({
10619
+ level: el.tagName.toLowerCase(),
10620
+ text: (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim(),
10621
+ }))
10622
+ .filter((heading) => heading.text);
10623
+ })()`);
10624
+ }
10625
+ function formatPageSummary(summary) {
10626
+ const lines = [`URL: ${summary.url}`, `Title: ${summary.title}`];
10627
+ lines.push("", "Headings:");
10628
+ if (summary.headings.length === 0) {
10629
+ lines.push(" (none)");
10630
+ } else {
10631
+ for (const heading of summary.headings) {
10632
+ lines.push(` ${heading.level}: ${heading.text}`);
10633
+ }
10634
+ }
10635
+ lines.push("", "Form fields:");
10636
+ if (summary.forms.length === 0) {
10637
+ lines.push(" (none)");
10638
+ } else {
10639
+ lines.push(...formatFormFieldsPretty(summary.forms));
10640
+ }
10641
+ lines.push("", "Actions:");
10642
+ if (summary.interactiveElements.length === 0) {
10643
+ lines.push(" (none)");
10644
+ } else {
10645
+ lines.push(...formatInteractiveElementsPretty(summary.interactiveElements, 20));
10646
+ }
10647
+ return lines.join("\n");
10648
+ }
10649
+ async function pageCommand(_args, globalOptions) {
10650
+ if (globalOptions.help) {
10651
+ process.stdout.write(`${PAGE_HELP}
10652
+ `);
10653
+ return;
10654
+ }
10655
+ const session = await resolveSession(globalOptions.session);
10656
+ const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
10657
+ try {
10658
+ const [url, title, headings, forms, snapshot] = await Promise.all([
10659
+ page.url(),
10660
+ page.title(),
10661
+ getHeadings(page),
10662
+ page.forms(),
10663
+ page.snapshot()
10664
+ ]);
10665
+ const summary = {
10666
+ url,
10667
+ title,
10668
+ headings,
10669
+ forms,
10670
+ interactiveElements: snapshot.interactiveElements
10671
+ };
10672
+ output(
10673
+ globalOptions.format === "json" ? summary : formatPageSummary(summary),
10674
+ globalOptions.format
10675
+ );
10676
+ await updateSession(session.id, { currentUrl: url });
10677
+ } finally {
10678
+ await browser.disconnect();
10679
+ }
10680
+ }
10681
+
9784
10682
  // src/cli/commands/quickstart.ts
9785
10683
  var QUICKSTART = `
9786
10684
  browser-pilot CLI - Quick Start Guide
@@ -9797,13 +10695,13 @@ STEP 3: GET PAGE SNAPSHOT
9797
10695
  bp snapshot -i
9798
10696
 
9799
10697
  Shows only interactive elements (buttons, inputs, links) with refs:
9800
- button "Sign In" [ref=e2]
9801
- textbox "Email" [ref=e3]
9802
- link "Forgot password?" [ref=e6]
10698
+ button "Sign In" ref:e2
10699
+ textbox "Email" ref:e3
10700
+ link "Forgot password?" ref:e6
9803
10701
 
9804
10702
  Other formats:
9805
10703
  bp snapshot --format text # Full accessibility tree (all elements)
9806
- bp snapshot # Full snapshot as JSON
10704
+ bp snapshot --json # Full snapshot as JSON
9807
10705
 
9808
10706
  STEP 4: INTERACT USING REFS
9809
10707
  bp exec '{"action":"fill","selector":"ref:e3","value":"test@example.com"}'
@@ -9822,6 +10720,13 @@ FOR AI AGENTS
9822
10720
  bp snapshot -i --json
9823
10721
  bp exec '{"action":"click","selector":"ref:e3"}' --json
9824
10722
 
10723
+ PAGE DISCOVERY SHORTCUTS
10724
+ bp page # URL, title, headings, forms, and interactive controls
10725
+ bp forms # Structured list of form fields only
10726
+ bp targets # All available browser tabs
10727
+ bp connect --new-tab --url https://example.com
10728
+ # Start from a fresh tab instead of reusing one
10729
+
9825
10730
  TIPS
9826
10731
  \u2022 Refs (e1, e2...) are stable within a page - prefer them over CSS selectors
9827
10732
  \u2022 After navigation, take a new snapshot to get updated refs
@@ -11636,7 +12541,9 @@ Usage:
11636
12541
 
11637
12542
  Options:
11638
12543
  -i, --interactive Show only interactive elements (buttons, inputs, links)
11639
- -f, --format <type> Output format: full | interactive | text (default: full)
12544
+ -f, --format <type> Output format: full | interactive | text (default: text)
12545
+ --role <roles> Filter snapshot to accessibility roles (for example: radio,checkbox)
12546
+ -o, --output <path> Write command output to a file instead of stdout
11640
12547
  -d, --diff <file> Compare current page against a saved snapshot JSON
11641
12548
  --inspect Inject visual ref labels onto the page (auto-removes after 10s)
11642
12549
  --keep Keep visual ref labels visible (use with --inspect)
@@ -11647,22 +12554,31 @@ Options:
11647
12554
  -h, --help Show this help
11648
12555
 
11649
12556
  Examples:
12557
+ bp snapshot # Full accessibility tree as readable text
11650
12558
  bp snapshot -i # Interactive elements only (best for AI agents)
11651
- bp snapshot --format text # Full accessibility tree as text
12559
+ bp snapshot --role radio,checkbox # Focus on specific control roles
11652
12560
  bp snapshot --json > page.json # Save full snapshot to file
11653
12561
  bp snapshot --diff before.json # Show what changed since before.json
11654
12562
  bp snapshot --inspect # Visual ref labels on the page
11655
12563
  `.trimEnd();
11656
12564
  function parseSnapshotArgs(args) {
11657
- const options = {};
12565
+ const options = {
12566
+ format: "text"
12567
+ };
11658
12568
  for (let i = 0; i < args.length; i++) {
11659
12569
  const arg = args[i];
11660
12570
  if (arg === "--format" || arg === "-f") {
11661
12571
  options.format = args[++i];
12572
+ options.formatExplicit = true;
11662
12573
  } else if (arg === "--diff" || arg === "-d") {
11663
12574
  options.diffFile = args[++i];
11664
12575
  } else if (arg === "--interactive" || arg === "-i") {
11665
12576
  options.format = "interactive";
12577
+ options.formatExplicit = true;
12578
+ } else if (arg === "--role") {
12579
+ options.roles = args[++i]?.split(",").map((role) => role.trim().toLowerCase()).filter(Boolean);
12580
+ } else if (arg === "--output" || arg === "-o") {
12581
+ options.outputFile = args[++i];
11666
12582
  } else if (arg === "--inspect") {
11667
12583
  options.inspect = true;
11668
12584
  } else if (arg === "--keep") {
@@ -11673,6 +12589,11 @@ function parseSnapshotArgs(args) {
11673
12589
  }
11674
12590
  return options;
11675
12591
  }
12592
+ function writeInfo(message, asStderr = false) {
12593
+ const stream = asStderr ? process.stderr : process.stdout;
12594
+ stream.write(message.endsWith("\n") ? message : `${message}
12595
+ `);
12596
+ }
11676
12597
  function sleep7(ms) {
11677
12598
  return new Promise((resolve3) => setTimeout(resolve3, ms));
11678
12599
  }
@@ -11698,7 +12619,8 @@ async function snapshotCommand(args, globalOptions) {
11698
12619
  });
11699
12620
  try {
11700
12621
  const page = await browser.page(void 0, { targetId: session.targetId });
11701
- const snapshot = await page.snapshot();
12622
+ const snapshot = await page.snapshot(options.roles?.length ? { roles: options.roles } : {});
12623
+ const infoToStderr = globalOptions.format === "json" || !!options.outputFile;
11702
12624
  await updateSession(session.id, {
11703
12625
  currentUrl: snapshot.url,
11704
12626
  metadata: {
@@ -11716,43 +12638,117 @@ async function snapshotCommand(args, globalOptions) {
11716
12638
  const beforeContent = fs2.readFileSync(options.diffFile, "utf-8");
11717
12639
  const beforeSnapshot = JSON.parse(beforeContent);
11718
12640
  const diff = diffSnapshots(beforeSnapshot, snapshot);
11719
- if (globalOptions.format === "json") {
12641
+ if (options.outputFile) {
12642
+ fs2.writeFileSync(options.outputFile, renderOutput(diff, globalOptions.format));
12643
+ writeInfo(`Wrote output to ${options.outputFile}`, true);
12644
+ } else if (globalOptions.format === "json") {
11720
12645
  output(diff, "json");
11721
12646
  } else {
11722
- console.log(formatDiffPretty(diff));
12647
+ writeInfo(formatDiffPretty(diff));
11723
12648
  }
11724
12649
  return;
11725
12650
  }
11726
12651
  if (options.inspect) {
11727
12652
  await injectRefOverlay(page, snapshot);
11728
- console.log("Overlay injected. Element refs are now visible on the page.");
12653
+ writeInfo("Overlay injected. Element refs are now visible on the page.", infoToStderr);
11729
12654
  if (options.keep) {
11730
- console.log(
11731
- "Overlay will remain visible. Use removeRefOverlay() or refresh the page to remove."
12655
+ writeInfo(
12656
+ "Overlay will remain visible. Use removeRefOverlay() or refresh the page to remove.",
12657
+ infoToStderr
11732
12658
  );
11733
12659
  } else {
11734
- console.log("Overlay will be removed in 10 seconds...");
12660
+ writeInfo("Overlay will be removed in 10 seconds...", infoToStderr);
11735
12661
  await sleep7(1e4);
11736
12662
  await removeRefOverlay(page);
11737
- console.log("Overlay removed.");
12663
+ writeInfo("Overlay removed.", infoToStderr);
11738
12664
  }
11739
12665
  }
11740
- switch (options.format) {
11741
- case "interactive":
11742
- output(snapshot.interactiveElements, globalOptions.format);
11743
- break;
11744
- case "text":
11745
- console.log(snapshot.text);
11746
- break;
11747
- default:
11748
- output(snapshot, globalOptions.format);
11749
- break;
12666
+ const shouldForceFullJson = globalOptions.format === "json" && !options.formatExplicit;
12667
+ let payload = snapshot;
12668
+ if (!shouldForceFullJson) {
12669
+ switch (options.format) {
12670
+ case "interactive":
12671
+ payload = snapshot.interactiveElements;
12672
+ break;
12673
+ case "text":
12674
+ payload = snapshot.text;
12675
+ break;
12676
+ default:
12677
+ payload = snapshot;
12678
+ break;
12679
+ }
12680
+ }
12681
+ if (options.outputFile) {
12682
+ fs2.writeFileSync(options.outputFile, renderOutput(payload, globalOptions.format));
12683
+ writeInfo(`Wrote output to ${options.outputFile}`, true);
12684
+ } else {
12685
+ output(payload, globalOptions.format);
11750
12686
  }
11751
12687
  } finally {
11752
12688
  await browser.disconnect();
11753
12689
  }
11754
12690
  }
11755
12691
 
12692
+ // src/cli/commands/targets.ts
12693
+ var TARGETS_HELP = `
12694
+ bp targets - List page tabs available in the connected browser
12695
+
12696
+ Usage:
12697
+ bp targets [options]
12698
+
12699
+ Options:
12700
+ -s, --session <id> Session to use (default: most recent)
12701
+ -f, --format <fmt> json | pretty (default: pretty)
12702
+ --json Alias for -f json
12703
+ --trace Enable debug tracing
12704
+ -h, --help Show this help
12705
+
12706
+ Examples:
12707
+ bp targets
12708
+ bp targets --json
12709
+ `.trimEnd();
12710
+ function formatTargetsPretty(targets) {
12711
+ if (targets.length === 0) {
12712
+ return "No page targets found.";
12713
+ }
12714
+ return targets.map((target) => {
12715
+ const lines = [
12716
+ `${target.title || "(untitled)"}`,
12717
+ ` targetId: ${target.targetId}`,
12718
+ ` url: ${target.url}`
12719
+ ];
12720
+ if (target.attached) {
12721
+ lines.push(" attached: true");
12722
+ }
12723
+ return lines.join("\n");
12724
+ }).join("\n\n");
12725
+ }
12726
+ async function targetsCommand(_args, globalOptions) {
12727
+ if (globalOptions.help) {
12728
+ process.stdout.write(`${TARGETS_HELP}
12729
+ `);
12730
+ return;
12731
+ }
12732
+ const session = globalOptions.session ? await loadSession(globalOptions.session) : await getDefaultSession();
12733
+ if (!session) {
12734
+ throw new Error('No session found. Run "bp connect" first.');
12735
+ }
12736
+ const browser = await connect({
12737
+ provider: session.provider,
12738
+ wsUrl: session.wsUrl,
12739
+ debug: globalOptions.trace
12740
+ });
12741
+ try {
12742
+ const targets = await browser.listTargets();
12743
+ output(
12744
+ globalOptions.format === "json" ? targets : formatTargetsPretty(targets),
12745
+ globalOptions.format
12746
+ );
12747
+ } finally {
12748
+ await browser.disconnect();
12749
+ }
12750
+ }
12751
+
11756
12752
  // src/cli/commands/text.ts
11757
12753
  var TEXT_HELP = `
11758
12754
  bp text - Extract text content from the current page
@@ -11832,6 +12828,9 @@ Commands:
11832
12828
  connect Create browser session
11833
12829
  exec Execute actions
11834
12830
  eval Evaluate JavaScript expression
12831
+ page Show a compact page overview
12832
+ forms List form controls on the page
12833
+ targets List available browser tabs
11835
12834
  run Run a workflow file (JSON steps)
11836
12835
  record Record browser actions to JSON
11837
12836
  audio Audio I/O for voice agent testing
@@ -11896,34 +12895,43 @@ function parseGlobalOptions(args) {
11896
12895
  return { options, remaining };
11897
12896
  }
11898
12897
  function output(data, format = "pretty") {
12898
+ const text = renderOutput(data, format);
12899
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
12900
+ `);
12901
+ }
12902
+ function renderOutput(data, format = "pretty") {
11899
12903
  if (format === "json") {
11900
- console.log(JSON.stringify(data, null, 2));
11901
- } else {
11902
- if (typeof data === "string") {
11903
- console.log(data);
11904
- } else if (typeof data === "object" && data !== null) {
11905
- const { truncated } = prettyPrint(data);
11906
- if (truncated) {
11907
- console.log("\n(Output truncated. Use --json for full data)");
11908
- }
11909
- } else {
11910
- console.log(data);
12904
+ return JSON.stringify(data, null, 2);
12905
+ }
12906
+ if (typeof data === "string") {
12907
+ return data;
12908
+ }
12909
+ if (Array.isArray(data)) {
12910
+ return JSON.stringify(data, null, 2);
12911
+ }
12912
+ if (typeof data === "object" && data !== null) {
12913
+ const lines = [];
12914
+ const { truncated } = prettyPrint(data, lines);
12915
+ if (truncated) {
12916
+ lines.push("", "(Output truncated. Use --json for full data)");
11911
12917
  }
12918
+ return lines.join("\n");
11912
12919
  }
12920
+ return String(data);
11913
12921
  }
11914
- function prettyPrint(obj, indent = 0) {
12922
+ function prettyPrint(obj, lines, indent = 0) {
11915
12923
  const prefix = " ".repeat(indent);
11916
12924
  let truncated = false;
11917
12925
  for (const [key, value] of Object.entries(obj)) {
11918
12926
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
11919
- console.log(`${prefix}${key}:`);
11920
- const result = prettyPrint(value, indent + 1);
12927
+ lines.push(`${prefix}${key}:`);
12928
+ const result = prettyPrint(value, lines, indent + 1);
11921
12929
  if (result.truncated) truncated = true;
11922
12930
  } else if (Array.isArray(value)) {
11923
- console.log(`${prefix}${key}: [${value.length} items]`);
12931
+ lines.push(`${prefix}${key}: [${value.length} items]`);
11924
12932
  truncated = true;
11925
12933
  } else {
11926
- console.log(`${prefix}${key}: ${value}`);
12934
+ lines.push(`${prefix}${key}: ${value}`);
11927
12935
  }
11928
12936
  }
11929
12937
  return { truncated };
@@ -11954,6 +12962,15 @@ async function main() {
11954
12962
  case "eval":
11955
12963
  await evalCommand(remaining, options);
11956
12964
  break;
12965
+ case "page":
12966
+ await pageCommand(remaining, options);
12967
+ break;
12968
+ case "forms":
12969
+ await formsCommand(remaining, options);
12970
+ break;
12971
+ case "targets":
12972
+ await targetsCommand(remaining, options);
12973
+ break;
11957
12974
  case "snapshot":
11958
12975
  await snapshotCommand(remaining, options);
11959
12976
  break;
@@ -12011,5 +13028,6 @@ if (import.meta.main) {
12011
13028
  }
12012
13029
  export {
12013
13030
  output,
12014
- parseGlobalOptions
13031
+ parseGlobalOptions,
13032
+ renderOutput
12015
13033
  };