@crouton-kit/capture 1.3.11 → 1.3.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/bin/capture CHANGED
@@ -8391,19 +8391,42 @@ async function navigateAndWait(port, url, options = {}) {
8391
8391
  throw new Error("New tab has no WebSocket debugger URL");
8392
8392
  }
8393
8393
  const client = new CDPClient(tab.webSocketDebuggerUrl);
8394
- await client.waitReady();
8395
- await client.send("Page.enable");
8396
- await new Promise((resolve11, reject) => {
8397
- const timer = setTimeout(
8398
- () => reject(new Error(`Page load timeout: ${url}`)),
8399
- timeout
8400
- );
8401
- client.on("Page.loadEventFired", () => {
8402
- clearTimeout(timer);
8403
- resolve11();
8394
+ try {
8395
+ await client.waitReady();
8396
+ await client.send("Page.enable");
8397
+ let timer;
8398
+ let loadHandler;
8399
+ let loadTimeoutError;
8400
+ let resolveLoad;
8401
+ const loadPromise = new Promise((resolve11) => {
8402
+ resolveLoad = resolve11;
8403
+ loadHandler = () => {
8404
+ if (timer) clearTimeout(timer);
8405
+ resolveLoad();
8406
+ };
8407
+ client.on("Page.loadEventFired", loadHandler);
8408
+ timer = setTimeout(() => {
8409
+ loadTimeoutError = new Error(`Page load timeout: ${url}`);
8410
+ resolveLoad();
8411
+ }, timeout);
8404
8412
  });
8405
- });
8406
- client.close();
8413
+ try {
8414
+ const result = await client.send("Runtime.evaluate", {
8415
+ expression: "document.readyState",
8416
+ returnByValue: true
8417
+ });
8418
+ const readyState = result?.result?.value;
8419
+ if (readyState !== "complete") {
8420
+ await loadPromise;
8421
+ if (loadTimeoutError) throw loadTimeoutError;
8422
+ }
8423
+ } finally {
8424
+ if (timer) clearTimeout(timer);
8425
+ if (loadHandler) client.off("Page.loadEventFired", loadHandler);
8426
+ }
8427
+ } finally {
8428
+ client.close();
8429
+ }
8407
8430
  const targets = await listTargets(port);
8408
8431
  const refreshed = targets.find((t) => t.id === tab.id);
8409
8432
  return refreshed ?? tab;
@@ -9799,7 +9822,7 @@ function validateHarAppendBatch(value, source) {
9799
9822
  return { entries, incompleteLifecycles };
9800
9823
  }
9801
9824
  function captureVersion() {
9802
- const version = "1.3.11";
9825
+ const version = "1.3.13";
9803
9826
  return typeof version === "string" && version.length > 0 ? version : "development";
9804
9827
  }
9805
9828
  function token() {
@@ -11558,7 +11581,7 @@ function wallTime(value) {
11558
11581
  }
11559
11582
  }
11560
11583
  function captureVersion2() {
11561
- const version = "1.3.11";
11584
+ const version = "1.3.13";
11562
11585
  return typeof version === "string" && version.length > 0 ? version : "development";
11563
11586
  }
11564
11587
  function deepFreeze(value) {
@@ -13819,9 +13842,9 @@ function readSession(id) {
13819
13842
  function generateId() {
13820
13843
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
13821
13844
  }
13822
- async function waitForPageLoad(client, timeoutMs) {
13845
+ async function waitForPageLoad(client, timeoutMs, attachTimeoutMs = SESSION_TAB_ATTACH_TIMEOUT_MS) {
13823
13846
  await client.waitReady();
13824
- await client.send("Page.enable");
13847
+ await client.send("Page.enable", {}, attachTimeoutMs);
13825
13848
  return await new Promise((resolve11) => {
13826
13849
  const timer = setTimeout(() => resolve11(true), timeoutMs);
13827
13850
  const done = (timedOut) => {
@@ -14558,7 +14581,7 @@ async function sessionMain(parsed, _args) {
14558
14581
  return;
14559
14582
  }
14560
14583
  }
14561
- var fs9, path12, VIEW_FILTERS, SECTION_LABELS, COMMAND_BLOCK, START_USAGE, STOP_USAGE, LIST_USAGE, VIEW_USAGE, HAR_USAGE, LOG_USAGE, productionStartWorld, startWorld;
14584
+ var fs9, path12, VIEW_FILTERS, SECTION_LABELS, SESSION_TAB_ATTACH_TIMEOUT_MS, COMMAND_BLOCK, START_USAGE, STOP_USAGE, LIST_USAGE, VIEW_USAGE, HAR_USAGE, LOG_USAGE, productionStartWorld, startWorld;
14562
14585
  var init_commands = __esm({
14563
14586
  "src/session/commands.ts"() {
14564
14587
  fs9 = __toESM(require("fs"));
@@ -14589,6 +14612,7 @@ var init_commands = __esm({
14589
14612
  recs: "motion",
14590
14613
  other: "other"
14591
14614
  };
14615
+ SESSION_TAB_ATTACH_TIMEOUT_MS = 5e3;
14592
14616
  COMMAND_BLOCK = `<command name="session">
14593
14617
  the artifact container \u2014 a session opens a tab, records HAR, and bundles every artifact; while active, every command auto-targets its tab
14594
14618
  use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
@@ -14728,7 +14752,14 @@ effects:
14728
14752
  } catch (err) {
14729
14753
  throw worldFailure(`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`, err);
14730
14754
  }
14731
- return await waitForPageLoad(client, 1e4);
14755
+ try {
14756
+ return await waitForPageLoad(client, 1e4);
14757
+ } catch (err) {
14758
+ throw worldFailure(
14759
+ `capture session start could not attach to ${url}: Page.enable failed during its ${SESSION_TAB_ATTACH_TIMEOUT_MS}ms attachment window (${err instanceof Error ? err.message : err}). The CDP endpoint created the tab but cannot drive it; choose another endpoint from \`capture tab list\` and retry with \`--port\`.`,
14760
+ err
14761
+ );
14762
+ }
14732
14763
  } finally {
14733
14764
  client.close();
14734
14765
  }
@@ -15075,7 +15106,13 @@ async function resolveLiveTarget(client, input) {
15075
15106
  return resolveLiveCss(client, input, parsed.value);
15076
15107
  }
15077
15108
  }
15078
- function settleAxMatches(kind, input, matches) {
15109
+ function preferActionable(matches) {
15110
+ if (matches.length <= 1) return matches;
15111
+ const interactive = matches.filter((m) => m.role?.value !== void 0 && INTERACTIVE_ROLES.has(m.role.value));
15112
+ return interactive.length === 1 ? interactive : matches;
15113
+ }
15114
+ function settleAxMatches(kind, input, rawMatches) {
15115
+ const matches = preferActionable(rawMatches);
15079
15116
  if (matches.length === 1) {
15080
15117
  const m = matches[0];
15081
15118
  return {
@@ -241019,7 +241056,7 @@ CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators \u2014 precedence:
241019
241056
  explicit flag > active session > env.`;
241020
241057
  }
241021
241058
  function printVersion() {
241022
- const declared = "1.3.11";
241059
+ const declared = "1.3.13";
241023
241060
  if (declared) {
241024
241061
  console.log(declared);
241025
241062
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.11",
3
+ "version": "1.3.13",
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"
package/src/cdp/record.ts CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  findTab,
4
4
  listTargets,
5
5
  openTab,
6
- type CDPTarget,
7
6
  } from './targets.js';
7
+ import { type CDPTarget } from './types.js';
8
8
 
9
9
  /**
10
10
  * Opens (or reuses) a tab for `url` and waits for its load event — `tab open`'s
@@ -32,23 +32,49 @@ export async function navigateAndWait(
32
32
  throw new Error('New tab has no WebSocket debugger URL');
33
33
  }
34
34
 
35
- // Wait for page load
35
+ // Wait for page load. The client owns a page WebSocket, so close it even
36
+ // when connection, readiness, timeout, or target-refresh work fails.
36
37
  const client = new CDPClient(tab.webSocketDebuggerUrl);
37
- await client.waitReady();
38
- await client.send('Page.enable');
38
+ try {
39
+ await client.waitReady();
40
+ await client.send('Page.enable');
39
41
 
40
- await new Promise<void>((resolve, reject) => {
41
- const timer = setTimeout(
42
- () => reject(new Error(`Page load timeout: ${url}`)),
43
- timeout,
44
- );
45
- client.on('Page.loadEventFired', () => {
46
- clearTimeout(timer);
47
- resolve();
42
+ let timer: NodeJS.Timeout | undefined;
43
+ let loadHandler: ((params: unknown) => void) | undefined;
44
+ let loadTimeoutError: Error | undefined;
45
+ let resolveLoad!: () => void;
46
+ const loadPromise = new Promise<void>((resolve) => {
47
+ resolveLoad = resolve;
48
+ loadHandler = () => {
49
+ if (timer) clearTimeout(timer);
50
+ resolveLoad();
51
+ };
52
+ // Arm the event before checking readyState: the load event may fire
53
+ // while the readiness query is in flight.
54
+ client.on('Page.loadEventFired', loadHandler);
55
+ timer = setTimeout(() => {
56
+ loadTimeoutError = new Error(`Page load timeout: ${url}`);
57
+ resolveLoad();
58
+ }, timeout);
48
59
  });
49
- });
50
60
 
51
- client.close();
61
+ try {
62
+ const result = await client.send('Runtime.evaluate', {
63
+ expression: 'document.readyState',
64
+ returnByValue: true,
65
+ });
66
+ const readyState = (result as { result?: { value?: unknown } } | undefined)?.result?.value;
67
+ if (readyState !== 'complete') {
68
+ await loadPromise;
69
+ if (loadTimeoutError) throw loadTimeoutError;
70
+ }
71
+ } finally {
72
+ if (timer) clearTimeout(timer);
73
+ if (loadHandler) client.off('Page.loadEventFired', loadHandler);
74
+ }
75
+ } finally {
76
+ client.close();
77
+ }
52
78
 
53
79
  // Refresh target info — find by ID to avoid returning a different tab with same URL
54
80
  const targets = await listTargets(port);
package/src/interact.ts CHANGED
@@ -18,11 +18,19 @@
18
18
  * nothing — command leaves own all output; returned facts carry the resolved
19
19
  * identity (`backendNodeId`, role, name) and the dispatched coordinates.
20
20
  *
21
+ * `ax:`/`axid:` tie-break: a same-name match set that mixes an
22
+ * {@link INTERACTIVE_ROLES} role (e.g. `button`) with non-interactive ones
23
+ * (e.g. `StaticText` — the common shape of a label rendering inside its own
24
+ * button) narrows to the interactive match when that leaves exactly one
25
+ * candidate, because that is the only element the caller can actually drive.
26
+ * A tie among multiple interactive roles (or zero) stays genuinely
27
+ * ambiguous — see {@link preferActionable}.
28
+ *
21
29
  * Uses getFullAXTree instead of queryAXTree because queryAXTree hangs on
22
30
  * complex pages (7000+ AX nodes) when given the DOM root nodeId.
23
31
  */
24
32
 
25
- import { readFullAXTree, type FullAXNode } from './cdp/a11y.js';
33
+ import { INTERACTIVE_ROLES, readFullAXTree, type FullAXNode } from './cdp/a11y.js';
26
34
  import { isScrollDestination } from './cdp/leaf-grammar.js';
27
35
  import { captureError } from './errors.js';
28
36
  import { parseSelectorInput, type SelectorInputKind } from './output/selector.js';
@@ -163,11 +171,26 @@ export async function resolveLiveTarget(
163
171
  }
164
172
  }
165
173
 
174
+ /**
175
+ * Narrows a same-name match set to its lone {@link INTERACTIVE_ROLES} member
176
+ * when the set mixes interactive and non-interactive roles — e.g. a `button`
177
+ * plus the `StaticText` label rendered inside it, both carrying the same
178
+ * accessible name. Leaves `matches` untouched when zero or more than one
179
+ * interactive role is present, so a genuine tie (two buttons, or no
180
+ * interactive role at all) still surfaces every candidate.
181
+ */
182
+ function preferActionable(matches: FullAXNode[]): FullAXNode[] {
183
+ if (matches.length <= 1) return matches;
184
+ const interactive = matches.filter((m) => m.role?.value !== undefined && INTERACTIVE_ROLES.has(m.role.value));
185
+ return interactive.length === 1 ? interactive : matches;
186
+ }
187
+
166
188
  function settleAxMatches(
167
189
  kind: LiveTargetKind,
168
190
  input: string,
169
- matches: FullAXNode[],
191
+ rawMatches: FullAXNode[],
170
192
  ): ResolvedTarget | ResolutionFailure {
193
+ const matches = preferActionable(rawMatches);
171
194
  if (matches.length === 1) {
172
195
  const m = matches[0];
173
196
  return {
@@ -286,12 +286,18 @@ function generateId(): string {
286
286
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
287
287
  }
288
288
 
289
+ /** A fresh target must acknowledge Page.enable promptly. Target.createTarget
290
+ * already succeeded; waiting a full CDP request timeout here only postpones a
291
+ * failed session start before it can publish or roll back its target. */
292
+ export const SESSION_TAB_ATTACH_TIMEOUT_MS = 5_000;
293
+
289
294
  export async function waitForPageLoad(
290
- client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
295
+ client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>, timeout?: number): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
291
296
  timeoutMs: number,
297
+ attachTimeoutMs = SESSION_TAB_ATTACH_TIMEOUT_MS,
292
298
  ): Promise<boolean> {
293
299
  await client.waitReady();
294
- await client.send('Page.enable');
300
+ await client.send('Page.enable', {}, attachTimeoutMs);
295
301
 
296
302
  return await new Promise<boolean>((resolve) => {
297
303
  const timer = setTimeout(() => resolve(true), timeoutMs);
@@ -502,7 +508,14 @@ const productionStartWorld: SessionStartWorld = {
502
508
  } catch (err) {
503
509
  throw worldFailure(`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`, err);
504
510
  }
505
- return await waitForPageLoad(client, 10_000);
511
+ try {
512
+ return await waitForPageLoad(client, 10_000);
513
+ } catch (err) {
514
+ throw worldFailure(
515
+ `capture session start could not attach to ${url}: Page.enable failed during its ${SESSION_TAB_ATTACH_TIMEOUT_MS}ms attachment window (${err instanceof Error ? err.message : err}). The CDP endpoint created the tab but cannot drive it; choose another endpoint from \`capture tab list\` and retry with \`--port\`.`,
516
+ err,
517
+ );
518
+ }
506
519
  } finally {
507
520
  client.close();
508
521
  }