@crouton-kit/capture 1.4.0 → 1.4.2

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/bin/capture CHANGED
@@ -2232,7 +2232,7 @@ var require_websocket = __commonJS({
2232
2232
  "use strict";
2233
2233
  var EventEmitter = require("events");
2234
2234
  var https = require("https");
2235
- var http = require("http");
2235
+ var http2 = require("http");
2236
2236
  var net5 = require("net");
2237
2237
  var tls = require("tls");
2238
2238
  var { randomBytes: randomBytes10, createHash: createHash5 } = require("crypto");
@@ -2766,7 +2766,7 @@ var require_websocket = __commonJS({
2766
2766
  }
2767
2767
  const defaultPort = isSecure ? 443 : 80;
2768
2768
  const key = randomBytes10(16).toString("base64");
2769
- const request = isSecure ? https.request : http.request;
2769
+ const request = isSecure ? https.request : http2.request;
2770
2770
  const protocolSet = /* @__PURE__ */ new Set();
2771
2771
  let perMessageDeflate;
2772
2772
  opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect);
@@ -3260,7 +3260,7 @@ var require_websocket_server = __commonJS({
3260
3260
  "node_modules/.pnpm/ws@8.20.0/node_modules/ws/lib/websocket-server.js"(exports2, module2) {
3261
3261
  "use strict";
3262
3262
  var EventEmitter = require("events");
3263
- var http = require("http");
3263
+ var http2 = require("http");
3264
3264
  var { Duplex } = require("stream");
3265
3265
  var { createHash: createHash5 } = require("crypto");
3266
3266
  var extension2 = require_extension();
@@ -3335,8 +3335,8 @@ var require_websocket_server = __commonJS({
3335
3335
  );
3336
3336
  }
3337
3337
  if (options.port != null) {
3338
- this._server = http.createServer((req, res) => {
3339
- const body = http.STATUS_CODES[426];
3338
+ this._server = http2.createServer((req, res) => {
3339
+ const body = http2.STATUS_CODES[426];
3340
3340
  res.writeHead(426, {
3341
3341
  "Content-Length": body.length,
3342
3342
  "Content-Type": "text/plain"
@@ -3623,7 +3623,7 @@ var require_websocket_server = __commonJS({
3623
3623
  this.destroy();
3624
3624
  }
3625
3625
  function abortHandshake(socket, code, message, headers2) {
3626
- message = message || http.STATUS_CODES[code];
3626
+ message = message || http2.STATUS_CODES[code];
3627
3627
  headers2 = {
3628
3628
  Connection: "close",
3629
3629
  "Content-Type": "text/html",
@@ -3632,7 +3632,7 @@ var require_websocket_server = __commonJS({
3632
3632
  };
3633
3633
  socket.once("finish", socket.destroy);
3634
3634
  socket.end(
3635
- `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r
3635
+ `HTTP/1.1 ${code} ${http2.STATUS_CODES[code]}\r
3636
3636
  ` + Object.keys(headers2).map((h) => `${h}: ${headers2[h]}`).join("\r\n") + "\r\n\r\n" + message
3637
3637
  );
3638
3638
  }
@@ -3779,6 +3779,39 @@ __export(detect_exports, {
3779
3779
  identifyBrowserBundleId: () => identifyBrowserBundleId,
3780
3780
  pickPreferredEndpoint: () => pickPreferredEndpoint
3781
3781
  });
3782
+ function httpGetJson(port, path33, timeoutMs) {
3783
+ return new Promise((resolve12) => {
3784
+ const req = http.get(
3785
+ { host: "localhost", port, path: path33, timeout: timeoutMs },
3786
+ (res) => {
3787
+ const status = res.statusCode ?? 0;
3788
+ if (status < 200 || status >= 300) {
3789
+ res.resume();
3790
+ resolve12(null);
3791
+ return;
3792
+ }
3793
+ let body = "";
3794
+ res.setEncoding("utf-8");
3795
+ res.on("data", (chunk) => {
3796
+ body += chunk;
3797
+ });
3798
+ res.on("end", () => {
3799
+ try {
3800
+ resolve12(JSON.parse(body));
3801
+ } catch {
3802
+ resolve12(null);
3803
+ }
3804
+ });
3805
+ res.on("error", () => resolve12(null));
3806
+ }
3807
+ );
3808
+ req.on("timeout", () => {
3809
+ req.destroy();
3810
+ resolve12(null);
3811
+ });
3812
+ req.on("error", () => resolve12(null));
3813
+ });
3814
+ }
3782
3815
  function getDefaultBrowserId() {
3783
3816
  try {
3784
3817
  const output = (0, import_child_process.execSync)(
@@ -3795,14 +3828,8 @@ function getDefaultBrowserId() {
3795
3828
  }
3796
3829
  async function probeCdpPort(port) {
3797
3830
  try {
3798
- const controller = new AbortController();
3799
- const timeout = setTimeout(() => controller.abort(), 500);
3800
- const resp = await fetch(`http://localhost:${port}/json/version`, {
3801
- signal: controller.signal
3802
- });
3803
- clearTimeout(timeout);
3804
- if (resp.ok) {
3805
- const version = await resp.json();
3831
+ const version = await httpGetJson(port, "/json/version", 500);
3832
+ if (version) {
3806
3833
  const browser = version.Browser;
3807
3834
  const userAgent = version["User-Agent"] ?? "";
3808
3835
  if (!browser) return null;
@@ -3824,14 +3851,7 @@ async function probeCdpPort(port) {
3824
3851
  }
3825
3852
  async function probeHasPageTarget(port) {
3826
3853
  try {
3827
- const controller = new AbortController();
3828
- const timeout = setTimeout(() => controller.abort(), 500);
3829
- const resp = await fetch(`http://localhost:${port}/json/list`, {
3830
- signal: controller.signal
3831
- });
3832
- clearTimeout(timeout);
3833
- if (!resp.ok) return false;
3834
- const list2 = await resp.json();
3854
+ const list2 = await httpGetJson(port, "/json/list", 500);
3835
3855
  return Array.isArray(list2) && list2.some((t) => t.type === "page");
3836
3856
  } catch {
3837
3857
  return false;
@@ -3926,10 +3946,11 @@ async function detectCdpPort() {
3926
3946
  }
3927
3947
  return pickPreferredEndpoint(endpoints, getDefaultBrowserId()).port;
3928
3948
  }
3929
- var import_child_process, BROWSER_PATTERNS;
3949
+ var import_child_process, http, BROWSER_PATTERNS;
3930
3950
  var init_detect = __esm({
3931
3951
  "src/cdp/detect.ts"() {
3932
3952
  import_child_process = require("child_process");
3953
+ http = __toESM(require("http"));
3933
3954
  BROWSER_PATTERNS = {
3934
3955
  "company.thebrowser.browser": ["arc"],
3935
3956
  "com.google.chrome": ["chrome", "google chrome"],
@@ -9059,7 +9080,7 @@ function assertScrollDestination(value) {
9059
9080
  function parseDoAction(action) {
9060
9081
  if (action.startsWith("click:")) {
9061
9082
  const target = action.slice("click:".length);
9062
- if (!target) throw invalidInput("Invalid --do action: click requires a target \u2014 a css selector, ax:<name>, axid:<id>, or backend:<id>.");
9083
+ if (!target) throw invalidInput("Invalid --do action: click requires a target \u2014 a css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, or backend:<id>.");
9063
9084
  return { verb: "click", target };
9064
9085
  }
9065
9086
  if (action.startsWith("scroll:")) {
@@ -9070,7 +9091,7 @@ function parseDoAction(action) {
9070
9091
  if (!isScrollDestination(to)) throw invalidInput("Invalid --do action: scroll requires `scroll:<target>,to=<top|bottom|px>`.");
9071
9092
  return { verb: "scroll", target: spec.slice(0, comma), to };
9072
9093
  }
9073
- throw invalidInput("Unsupported --do action. Supported actions: click:<target>; scroll:<target>,to=<top|bottom|px> \u2014 target is a css selector, ax:<name>, axid:<id>, or backend:<id>.");
9094
+ throw invalidInput("Unsupported --do action. Supported actions: click:<target>; scroll:<target>,to=<top|bottom|px> \u2014 target is a css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, or backend:<id>.");
9074
9095
  }
9075
9096
  var SWEEP_NUMERIC_AXES, SWEEP_ENUM_VALUES;
9076
9097
  var init_leaf_grammar = __esm({
@@ -10295,7 +10316,7 @@ function validateHarAppendBatch(value, source) {
10295
10316
  return { entries, incompleteLifecycles };
10296
10317
  }
10297
10318
  function captureVersion() {
10298
- const version = "1.4.0";
10319
+ const version = "1.4.2";
10299
10320
  return typeof version === "string" && version.length > 0 ? version : "development";
10300
10321
  }
10301
10322
  function token() {
@@ -12086,7 +12107,7 @@ function wallTime(value) {
12086
12107
  }
12087
12108
  }
12088
12109
  function captureVersion2() {
12089
- const version = "1.4.0";
12110
+ const version = "1.4.2";
12090
12111
  return typeof version === "string" && version.length > 0 ? version : "development";
12091
12112
  }
12092
12113
  function deepFreeze(value) {
@@ -16027,7 +16048,7 @@ async function resolveLiveTarget(client, input) {
16027
16048
  return settleAxMatches("axid", input, matches);
16028
16049
  }
16029
16050
  case "css":
16030
- return resolveLiveCss(client, input, parsed.value);
16051
+ return resolveBareTarget(client, input, parsed.value);
16031
16052
  }
16032
16053
  }
16033
16054
  function preferActionable(matches) {
@@ -16060,6 +16081,17 @@ function settleAxMatches(kind, input, rawMatches) {
16060
16081
  }))
16061
16082
  };
16062
16083
  }
16084
+ async function resolveBareTarget(client, input, selector) {
16085
+ const css = await resolveLiveCss(client, input, selector);
16086
+ if (css.ok || css.code === "ambiguous") return css;
16087
+ const normalized2 = normalizeAccessibleName(input);
16088
+ if (!normalized2) return css;
16089
+ const matches = (await readFullAXTree(client)).filter(
16090
+ (node) => node.backendDOMNodeId !== void 0 && node.name?.value !== void 0 && normalizeAccessibleName(node.name.value) === normalized2
16091
+ );
16092
+ const ax = settleAxMatches("ax", input, matches);
16093
+ return ax.ok || ax.code === "ambiguous" ? ax : css;
16094
+ }
16063
16095
  async function resolveLiveCss(client, input, selector) {
16064
16096
  await client.send("DOM.enable");
16065
16097
  const { root } = await client.send("DOM.getDocument", { depth: 0 });
@@ -16287,7 +16319,7 @@ function emitResolutionError(parsed, command, failure) {
16287
16319
  {
16288
16320
  tag: "error",
16289
16321
  attrs: { command, code: "unsupported_prefix" },
16290
- summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector, ax:<name>, axid:<id>, backend:<id>.`,
16322
+ summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
16291
16323
  followUp: fact`Retry \`${command}\` with \`ax:<name>\` (accessible-name substring) or a CSS selector; \`text:\` matching exists only on the query leaves.`
16292
16324
  },
16293
16325
  { json: parsed.json }
@@ -16339,7 +16371,7 @@ async function cmdPageClick(parsed, _args) {
16339
16371
  return emitInvalidInput(
16340
16372
  parsed,
16341
16373
  "page click",
16342
- fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>).`
16374
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>).`
16343
16375
  );
16344
16376
  }
16345
16377
  const settle = effectiveSettle(parsed, { standalone: 1e3, session: 2500 });
@@ -16390,7 +16422,7 @@ var init_click = __esm({
16390
16422
  USAGE = `capture page click <target> \u2014 dispatch one real click on exactly one resolved element
16391
16423
 
16392
16424
  input:
16393
- <target> resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required \u2014 zero or many matches is a structured error listing candidates with backend:<id> retry keys.
16425
+ <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required \u2014 zero or many matches is a structured error listing candidates with backend:<id> retry keys.
16394
16426
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
16395
16427
  --no-screenshot skip the auto-screenshot
16396
16428
  output:
@@ -16466,7 +16498,7 @@ var init_type = __esm({
16466
16498
 
16467
16499
  input:
16468
16500
  <text> the text to insert (agent-supplied; echoed back in the result block)
16469
- --into <target> focus this field first \u2014 resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required
16501
+ --into <target> focus this field first \u2014 resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required
16470
16502
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
16471
16503
  --no-screenshot skip the auto-screenshot
16472
16504
  output:
@@ -16487,7 +16519,7 @@ async function cmdPageScroll(parsed, _args) {
16487
16519
  return emitInvalidInput(
16488
16520
  parsed,
16489
16521
  "page scroll",
16490
- fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`
16522
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`
16491
16523
  );
16492
16524
  }
16493
16525
  if (parsed.to === void 0) {
@@ -16554,7 +16586,7 @@ var init_scroll = __esm({
16554
16586
  USAGE3 = `capture page scroll <target> --to <top|bottom|px> \u2014 scroll one resolved container to a position
16555
16587
 
16556
16588
  input:
16557
- <target> resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required \u2014 zero or many matches is a structured error listing candidates
16589
+ <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required \u2014 zero or many matches is a structured error listing candidates
16558
16590
  --to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
16559
16591
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
16560
16592
  --no-screenshot skip the auto-screenshot
@@ -64688,8 +64720,8 @@ ${lanes.join("\n")}
64688
64720
  }
64689
64721
  function createPerModuleNameCache() {
64690
64722
  const directoryPathMap = /* @__PURE__ */ new Map();
64691
- return { get, set };
64692
- function get(directory) {
64723
+ return { get: get2, set };
64724
+ function get2(directory) {
64693
64725
  return directoryPathMap.get(toPath(directory, currentDirectory, getCanonicalFileName));
64694
64726
  }
64695
64727
  function set(directory, result) {
@@ -168021,12 +168053,12 @@ ${lanes.join("\n")}
168021
168053
  ...info,
168022
168054
  parseable: !!content,
168023
168055
  fileName,
168024
- get,
168056
+ get: get2,
168025
168057
  has(dependencyName, inGroups) {
168026
- return !!get(dependencyName, inGroups);
168058
+ return !!get2(dependencyName, inGroups);
168027
168059
  }
168028
168060
  };
168029
- function get(dependencyName, inGroups = 15) {
168061
+ function get2(dependencyName, inGroups = 15) {
168030
168062
  for (const [group2, deps7] of dependencyGroups) {
168031
168063
  if (deps7 && inGroups & group2) {
168032
168064
  const dep = deps7.get(dependencyName);
@@ -228091,7 +228123,7 @@ async function cmdPageShot(parsed, _args) {
228091
228123
  return;
228092
228124
  }
228093
228125
  if (parsed.cropSelector !== void 0 && !parsed.cropSelector.trim()) {
228094
- emitResult({ tag: "error", attrs: { command: "page shot", code: "invalid_crop_selector" }, summary: text`received an empty --crop-selector; expected a bare CSS selector, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
228126
+ emitResult({ tag: "error", attrs: { command: "page shot", code: "invalid_crop_selector" }, summary: text`received an empty --crop-selector; expected a bare CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
228095
228127
  process.exitCode = 1;
228096
228128
  return;
228097
228129
  }
@@ -228202,7 +228234,7 @@ input:
228202
228234
  --full-page transient override to the full scrollable content height for this capture
228203
228235
  --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
228204
228236
  --crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
228205
- --crop-selector <sel> resolve exactly one live target (bare CSS, ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
228237
+ --crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
228206
228238
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
228207
228239
  --zoom <factor> positive decimal CSS-to-image scale requested for a CSS crop; the 1600px image cap may produce a smaller reported scale
228208
228240
  --out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
@@ -228343,7 +228375,7 @@ use when driving or inspecting the tab a session opened or adopted; all verbs au
228343
228375
 
228344
228376
  All verbs auto-target the active session tab; --target/--url override. The
228345
228377
  driving verbs (click, type, scroll) resolve exactly one element via the
228346
- unified target grammar \u2014 bare CSS selector, ax:<name>, axid:<id>,
228378
+ unified target grammar \u2014 bare CSS selector (which takes precedence) or exact accessible name when CSS finds none, ax:<name>, axid:<id>,
228347
228379
  backend:<id> \u2014 and reject an ambiguous target with the candidate list.
228348
228380
  During a live composed recording every verb routes through the recorder.
228349
228381
 
@@ -241487,7 +241519,7 @@ function recorderLiveClient(recorder) {
241487
241519
  function resolutionError(failure) {
241488
241520
  if (failure.code === "unsupported-prefix") {
241489
241521
  return new DoActionError(
241490
- `Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector, ax:<name>, axid:<id>, backend:<id>.`,
241522
+ `Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
241491
241523
  "unsupported_target_prefix"
241492
241524
  );
241493
241525
  }
@@ -241793,7 +241825,7 @@ var init_rec = __esm({
241793
241825
  input:
241794
241826
  [url] --do <action> one-shot: open <url> (or record the active session tab when <url> is omitted), record one action, finalize
241795
241827
  action click:<target> | scroll:<target>,to=<top|bottom|px>
241796
- target css selector (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
241828
+ target css selector (takes precedence) or exact accessible name when CSS finds none (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
241797
241829
  must resolve to exactly one live element; text: is not accepted by driving actions
241798
241830
  --duration <seconds> keep recording after the action (default: 0)
241799
241831
  --viewport <WxH> emulate a viewport for the recording window (restored after); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
@@ -243312,7 +243344,7 @@ precedence: explicit flag > active session > env. \`session start\` adopts only
243312
243344
  explicit --target.`;
243313
243345
  }
243314
243346
  function printVersion() {
243315
- const declared = "1.4.0";
243347
+ const declared = "1.4.2";
243316
243348
  if (declared) {
243317
243349
  console.log(declared);
243318
243350
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
@@ -72,7 +72,7 @@ const USAGE = `capture motion rec — record the page over time: composed (whate
72
72
  input:
73
73
  [url] --do <action> one-shot: open <url> (or record the active session tab when <url> is omitted), record one action, finalize
74
74
  action click:<target> | scroll:<target>,to=<top|bottom|px>
75
- target css selector (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
75
+ target css selector (takes precedence) or exact accessible name when CSS finds none (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
76
76
  must resolve to exactly one live element; text: is not accepted by driving actions
77
77
  --duration <seconds> keep recording after the action (default: 0)
78
78
  --viewport <WxH> emulate a viewport for the recording window (restored after); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
@@ -212,7 +212,7 @@ function recorderLiveClient(recorder: Pick<RecorderSession, 'handleCdp'>): LiveC
212
212
  function resolutionError(failure: ResolutionFailure): DoActionError {
213
213
  if (failure.code === 'unsupported-prefix') {
214
214
  return new DoActionError(
215
- `Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector, ax:<name>, axid:<id>, backend:<id>.`,
215
+ `Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
216
216
  'unsupported_target_prefix',
217
217
  );
218
218
  }
@@ -112,7 +112,7 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
112
112
  {
113
113
  tag: 'error',
114
114
  attrs: { command, code: 'unsupported_prefix' },
115
- summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector, ax:<name>, axid:<id>, backend:<id>.`,
115
+ summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
116
116
  followUp: fact`Retry \`${command}\` with \`ax:<name>\` (accessible-name substring) or a CSS selector; \`text:\` matching exists only on the query leaves.`,
117
117
  },
118
118
  { json: parsed.json },
@@ -170,7 +170,7 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
170
170
  const USAGE = `capture page click <target> — dispatch one real click on exactly one resolved element
171
171
 
172
172
  input:
173
- <target> resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required — zero or many matches is a structured error listing candidates with backend:<id> retry keys.
173
+ <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required — zero or many matches is a structured error listing candidates with backend:<id> retry keys.
174
174
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
175
175
  --no-screenshot skip the auto-screenshot
176
176
  output:
@@ -188,7 +188,7 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
188
188
  return emitInvalidInput(
189
189
  parsed,
190
190
  'page click',
191
- fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>).`,
191
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>).`,
192
192
  );
193
193
  }
194
194
 
@@ -30,7 +30,7 @@ export const PAGE_USAGE = `capture page — verbs against the live session tab:
30
30
 
31
31
  All verbs auto-target the active session tab; --target/--url override. The
32
32
  driving verbs (click, type, scroll) resolve exactly one element via the
33
- unified target grammar — bare CSS selector, ax:<name>, axid:<id>,
33
+ unified target grammar — bare CSS selector (which takes precedence) or exact accessible name when CSS finds none, ax:<name>, axid:<id>,
34
34
  backend:<id> — and reject an ambiguous target with the candidate list.
35
35
  During a live composed recording every verb routes through the recorder.
36
36
 
@@ -22,7 +22,7 @@ import {
22
22
  const USAGE = `capture page scroll <target> --to <top|bottom|px> — scroll one resolved container to a position
23
23
 
24
24
  input:
25
- <target> resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates
25
+ <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates
26
26
  --to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
27
27
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
28
28
  --no-screenshot skip the auto-screenshot
@@ -41,7 +41,7 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
41
41
  return emitInvalidInput(
42
42
  parsed,
43
43
  'page scroll',
44
- fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`,
44
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`,
45
45
  );
46
46
  }
47
47
  if (parsed.to === undefined) {
@@ -42,7 +42,7 @@ input:
42
42
  --full-page transient override to the full scrollable content height for this capture
43
43
  --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
44
44
  --crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
45
- --crop-selector <sel> resolve exactly one live target (bare CSS, ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
45
+ --crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
46
46
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
47
47
  --zoom <factor> positive decimal CSS-to-image scale requested for a CSS crop; the 1600px image cap may produce a smaller reported scale
48
48
  --out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
@@ -266,7 +266,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
266
266
  return;
267
267
  }
268
268
  if (parsed.cropSelector !== undefined && !parsed.cropSelector.trim()) {
269
- emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop_selector' }, summary: text`received an empty --crop-selector; expected a bare CSS selector, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
269
+ emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop_selector' }, summary: text`received an empty --crop-selector; expected a bare CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
270
270
  process.exitCode = 1;
271
271
  return;
272
272
  }
@@ -28,7 +28,7 @@ const USAGE = `capture page type <text> [--into <target>] — type text into the
28
28
 
29
29
  input:
30
30
  <text> the text to insert (agent-supplied; echoed back in the result block)
31
- --into <target> focus this field first — resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required
31
+ --into <target> focus this field first — resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required
32
32
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
33
33
  --no-screenshot skip the auto-screenshot
34
34
  output:
package/src/cdp/detect.ts CHANGED
@@ -1,4 +1,56 @@
1
1
  import { execSync } from 'child_process';
2
+ import * as http from 'http';
3
+
4
+ /**
5
+ * A CDP-endpoint JSON probe that CANNOT leak a socket handle. Node's built-in
6
+ * `fetch` (undici) leaves a dangling socket + request handle when a
7
+ * connect-phase abort fires against a port that accepts TCP but never sends an
8
+ * HTTP response (e.g. a docker-proxied or half-open listener). That handle
9
+ * keeps this short-lived CLI process alive for undici's ~7s internal socket
10
+ * timeout AFTER all real work is done, so a single dead port on the machine
11
+ * adds ~7s to every command that runs port detection. A raw `http.get` whose
12
+ * request we explicitly `destroy()` on timeout releases the handle immediately.
13
+ *
14
+ * Resolves to the parsed JSON body on a 2xx response, or `null` on any
15
+ * timeout, connection error, non-2xx status, or unparseable body.
16
+ */
17
+ function httpGetJson(
18
+ port: number,
19
+ path: string,
20
+ timeoutMs: number,
21
+ ): Promise<unknown | null> {
22
+ return new Promise((resolve) => {
23
+ const req = http.get(
24
+ { host: 'localhost', port, path, timeout: timeoutMs },
25
+ (res) => {
26
+ const status = res.statusCode ?? 0;
27
+ if (status < 200 || status >= 300) {
28
+ res.resume();
29
+ resolve(null);
30
+ return;
31
+ }
32
+ let body = '';
33
+ res.setEncoding('utf-8');
34
+ res.on('data', (chunk) => {
35
+ body += chunk;
36
+ });
37
+ res.on('end', () => {
38
+ try {
39
+ resolve(JSON.parse(body));
40
+ } catch {
41
+ resolve(null);
42
+ }
43
+ });
44
+ res.on('error', () => resolve(null));
45
+ },
46
+ );
47
+ req.on('timeout', () => {
48
+ req.destroy();
49
+ resolve(null);
50
+ });
51
+ req.on('error', () => resolve(null));
52
+ });
53
+ }
2
54
 
3
55
  export const BROWSER_PATTERNS: Record<string, string[]> = {
4
56
  'company.thebrowser.browser': ['arc'],
@@ -40,18 +92,12 @@ interface CdpProbeResult {
40
92
  // being confused for the intended browser.
41
93
  async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
42
94
  try {
43
- const controller = new AbortController();
44
- const timeout = setTimeout(() => controller.abort(), 500);
45
- const resp = await fetch(`http://localhost:${port}/json/version`, {
46
- signal: controller.signal,
47
- });
48
- clearTimeout(timeout);
49
- if (resp.ok) {
50
- const version = (await resp.json()) as {
51
- Browser?: string;
52
- 'User-Agent'?: string;
53
- webSocketDebuggerUrl?: string;
54
- };
95
+ const version = (await httpGetJson(port, '/json/version', 500)) as {
96
+ Browser?: string;
97
+ 'User-Agent'?: string;
98
+ webSocketDebuggerUrl?: string;
99
+ } | null;
100
+ if (version) {
55
101
  const browser = version.Browser;
56
102
  const userAgent = version['User-Agent'] ?? '';
57
103
  if (!browser) return null;
@@ -83,14 +129,9 @@ async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
83
129
 
84
130
  async function probeHasPageTarget(port: number): Promise<boolean> {
85
131
  try {
86
- const controller = new AbortController();
87
- const timeout = setTimeout(() => controller.abort(), 500);
88
- const resp = await fetch(`http://localhost:${port}/json/list`, {
89
- signal: controller.signal,
90
- });
91
- clearTimeout(timeout);
92
- if (!resp.ok) return false;
93
- const list = (await resp.json()) as Array<{ type?: string }>;
132
+ const list = (await httpGetJson(port, '/json/list', 500)) as Array<{
133
+ type?: string;
134
+ }> | null;
94
135
  return Array.isArray(list) && list.some((t) => t.type === 'page');
95
136
  } catch {
96
137
  return false;
@@ -84,7 +84,7 @@ export type DoAction =
84
84
  export function parseDoAction(action: string): DoAction {
85
85
  if (action.startsWith('click:')) {
86
86
  const target = action.slice('click:'.length);
87
- if (!target) throw invalidInput('Invalid --do action: click requires a target — a css selector, ax:<name>, axid:<id>, or backend:<id>.');
87
+ if (!target) throw invalidInput('Invalid --do action: click requires a target — a css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, or backend:<id>.');
88
88
  return { verb: 'click', target };
89
89
  }
90
90
  if (action.startsWith('scroll:')) {
@@ -95,5 +95,5 @@ export function parseDoAction(action: string): DoAction {
95
95
  if (!isScrollDestination(to)) throw invalidInput('Invalid --do action: scroll requires `scroll:<target>,to=<top|bottom|px>`.');
96
96
  return { verb: 'scroll', target: spec.slice(0, comma), to };
97
97
  }
98
- throw invalidInput('Unsupported --do action. Supported actions: click:<target>; scroll:<target>,to=<top|bottom|px> — target is a css selector, ax:<name>, axid:<id>, or backend:<id>.');
98
+ throw invalidInput('Unsupported --do action. Supported actions: click:<target>; scroll:<target>,to=<top|bottom|px> — target is a css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, or backend:<id>.');
99
99
  }
package/src/interact.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * (`src/output/selector.ts`'s `parseSelectorInput`) and resolves it against
7
7
  * the LIVE page (not collector-stored records), per prefix:
8
8
  *
9
- * bare string live CSS query — `DOM.getDocument` + `DOM.querySelectorAll`
9
+ * bare string live CSS query, or an exact accessible-name match when CSS finds none
10
10
  * backend:<id> identity — the canonical retry key in every resolution failure
11
11
  * ax:<name> case-insensitive SUBSTRING over live `Accessibility.getFullAXTree` names
12
12
  * axid:<id> AX node id from the same live fetch
@@ -172,7 +172,7 @@ export async function resolveLiveTarget(
172
172
  return settleAxMatches('axid', input, matches);
173
173
  }
174
174
  case 'css':
175
- return resolveLiveCss(client, input, parsed.value);
175
+ return resolveBareTarget(client, input, parsed.value);
176
176
  }
177
177
  }
178
178
 
@@ -220,6 +220,32 @@ function settleAxMatches(
220
220
  };
221
221
  }
222
222
 
223
+ /**
224
+ * Bare targets preserve CSS behavior when they select one or many elements;
225
+ * when CSS selects none (or rejects the string), an exact accessible-name
226
+ * lookup lets an element name emitted by `page elements` drive directly.
227
+ * `ax:` remains the explicit case-insensitive substring query.
228
+ */
229
+ async function resolveBareTarget(
230
+ client: LiveClient,
231
+ input: string,
232
+ selector: string,
233
+ ): Promise<ResolvedTarget | ResolutionFailure> {
234
+ const css = await resolveLiveCss(client, input, selector);
235
+ if (css.ok || css.code === 'ambiguous') return css;
236
+
237
+ const normalized = normalizeAccessibleName(input);
238
+ if (!normalized) return css;
239
+ const matches = (await readFullAXTree(client)).filter(
240
+ (node) =>
241
+ node.backendDOMNodeId !== undefined &&
242
+ node.name?.value !== undefined &&
243
+ normalizeAccessibleName(node.name.value) === normalized,
244
+ );
245
+ const ax = settleAxMatches('ax', input, matches);
246
+ return ax.ok || ax.code === 'ambiguous' ? ax : css;
247
+ }
248
+
223
249
  async function resolveLiveCss(
224
250
  client: LiveClient,
225
251
  input: string,