@crouton-kit/capture 1.4.15 → 1.4.17

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
@@ -5910,6 +5910,7 @@ var targets_exports = {};
5910
5910
  __export(targets_exports, {
5911
5911
  AmbiguousPageTargetError: () => AmbiguousPageTargetError,
5912
5912
  closeTarget: () => closeTarget,
5913
+ findResponsivePageTabByUrl: () => findResponsivePageTabByUrl,
5913
5914
  findTab: () => findTab,
5914
5915
  findTabById: () => findTabById,
5915
5916
  findTabByIdAcrossEndpoints: () => findTabByIdAcrossEndpoints,
@@ -6009,11 +6010,12 @@ function normalizeUrlForMatch(value) {
6009
6010
  try {
6010
6011
  const url = new URL(value);
6011
6012
  const path48 = url.pathname.replace(/\/+$/, "") || "/";
6013
+ const hostname = url.hostname.toLowerCase();
6014
+ const loopback = hostname === "localhost" || hostname === "127.0.0.1";
6015
+ const port = url.port || (url.protocol === "http:" ? "80" : url.protocol === "https:" ? "443" : "");
6012
6016
  return {
6013
6017
  full: `${url.origin}${path48}${url.search}${url.hash}`.toLowerCase(),
6014
- // `host` (not `hostname`): two local dev servers on the same loopback
6015
- // address but different ports must not register as a host match.
6016
- host: url.host.toLowerCase(),
6018
+ origin: `${url.protocol}//${loopback ? "loopback" : hostname}:${port}`,
6017
6019
  path: path48.toLowerCase()
6018
6020
  };
6019
6021
  } catch {
@@ -6025,14 +6027,11 @@ function scoreTabUrlMatch(tabUrl, requestedUrl) {
6025
6027
  const requested = normalizeUrlForMatch(requestedUrl);
6026
6028
  if (tab && requested) {
6027
6029
  if (tab.full === requested.full) return 100;
6028
- if (tab.host === requested.host && tab.path === requested.path) return 95;
6029
- if (tab.host === requested.host && tab.path.startsWith(requested.path)) return 90;
6030
- if (tab.host === requested.host && requested.path.startsWith(tab.path)) return 85;
6031
- if (tab.host === requested.host) return 70;
6032
- if (tab.full.includes(requested.full)) return 60;
6033
- if (tab.full.includes(requested.host)) return 50;
6034
- if (requested.full.includes(tab.full)) return 40;
6035
- return 0;
6030
+ if (tab.origin !== requested.origin) return 0;
6031
+ if (tab.path === requested.path) return 95;
6032
+ if (tab.path.startsWith(requested.path)) return 90;
6033
+ if (requested.path.startsWith(tab.path)) return 85;
6034
+ return 70;
6036
6035
  }
6037
6036
  const tabLower = tabUrl.toLowerCase();
6038
6037
  const requestedLower = requestedUrl.toLowerCase();
@@ -6049,6 +6048,25 @@ async function findTabByIdAcrossEndpoints(targetId, preferredPort) {
6049
6048
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
6050
6049
  return findTabByIdInPorts(targetId, ports);
6051
6050
  }
6051
+ async function findResponsivePageTabByUrl(port, url) {
6052
+ const pages = (await listTargets(port)).filter((target) => target.type === "page");
6053
+ const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
6054
+ const bestScore = Math.max(0, ...scores);
6055
+ if (bestScore === 0) return null;
6056
+ for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
6057
+ if (!target.webSocketDebuggerUrl) continue;
6058
+ const client = new CDPClient(target.webSocketDebuggerUrl);
6059
+ try {
6060
+ await client.waitReady();
6061
+ await client.send("Page.captureScreenshot", { format: "png" }, 5e3);
6062
+ return target;
6063
+ } catch {
6064
+ } finally {
6065
+ client.close();
6066
+ }
6067
+ }
6068
+ return null;
6069
+ }
6052
6070
  async function findTabByUrlAcrossEndpoints(url, preferredPort) {
6053
6071
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
6054
6072
  let best = null;
@@ -10541,7 +10559,7 @@ function validateHarAppendBatch(value, source) {
10541
10559
  return { entries, incompleteLifecycles };
10542
10560
  }
10543
10561
  function captureVersion() {
10544
- const version = "1.4.15";
10562
+ const version = "1.4.17";
10545
10563
  return typeof version === "string" && version.length > 0 ? version : "development";
10546
10564
  }
10547
10565
  function token() {
@@ -12643,7 +12661,7 @@ function wallTime(value) {
12643
12661
  }
12644
12662
  }
12645
12663
  function captureVersion2() {
12646
- const version = "1.4.15";
12664
+ const version = "1.4.17";
12647
12665
  return typeof version === "string" && version.length > 0 ? version : "development";
12648
12666
  }
12649
12667
  function deepFreeze(value) {
@@ -15140,6 +15158,7 @@ async function start(parsed) {
15140
15158
  let bridgeSocket = null;
15141
15159
  let bridgePid = null;
15142
15160
  let cdpPort = null;
15161
+ let targetWasOpened = false;
15143
15162
  let selectedBrowser = null;
15144
15163
  let endpointSelectionReason = null;
15145
15164
  try {
@@ -15166,22 +15185,28 @@ async function start(parsed) {
15166
15185
  }
15167
15186
  }
15168
15187
  if (url) {
15188
+ let opened = false;
15169
15189
  try {
15170
15190
  target = await startWorld.openTab(cdpPort, url);
15191
+ opened = true;
15192
+ targetWasOpened = true;
15171
15193
  } catch (error4) {
15172
15194
  const message = error4 instanceof Error ? error4.message : String(error4);
15173
- if (message.toLowerCase().includes("not supported")) {
15195
+ if (!message.toLowerCase().includes("not supported")) throw error4;
15196
+ target = await startWorld.findResponsivePageTabByUrl?.(cdpPort, url) ?? null;
15197
+ if (!target) {
15174
15198
  throw worldFailure(
15175
- `Target.createTarget is unsupported on port ${cdpPort}. Use an existing tab: \`capture tab list --port ${cdpPort}\`, then \`capture session start --target <target-id> --port ${cdpPort}\`.`,
15199
+ `Target.createTarget is unsupported on port ${cdpPort}, and no matching existing page target answered a screenshot. Run \`capture tab list --port ${cdpPort}\` to inspect its targets.`,
15176
15200
  error4
15177
15201
  );
15178
15202
  }
15179
- throw error4;
15180
15203
  }
15181
- const openedPort = cdpPort;
15182
- const openedTargetId = target.id;
15183
- acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15184
- pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15204
+ if (opened) {
15205
+ const openedPort = cdpPort;
15206
+ const openedTargetId = target.id;
15207
+ acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15208
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15209
+ }
15185
15210
  }
15186
15211
  if (hold) {
15187
15212
  const bridge = await startWorld.startBridge(dir, cdpPort);
@@ -15211,7 +15236,7 @@ async function start(parsed) {
15211
15236
  return;
15212
15237
  }
15213
15238
  const rows = [fact`bundle dir: ${dir}`];
15214
- if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
15239
+ if (target) rows.push(targetWasOpened ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
15215
15240
  if (cdpPort !== null) rows.push(
15216
15241
  parsed.portSource === "env" ? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.` : parsed.port !== void 0 ? fact`CDP endpoint on port ${cdpPort} selected by explicit --port; --port overrides endpoint selection and --target overrides tab selection.` : fact`${selectedBrowser ?? "CDP endpoint"} on port ${cdpPort} auto-selected because it ${endpointSelectionReason ?? "was selected by automatic endpoint detection"}; capture-launched browser recency does not affect selection. --port overrides endpoint selection and --target overrides tab selection.`
15217
15242
  );
@@ -16128,10 +16153,10 @@ use when collecting or reading the traffic, logs, and artifacts of scoped work a
16128
16153
 
16129
16154
  input:
16130
16155
  --url <url> Absolute URL to open in a fresh tab through CDP's
16131
- Target.createTarget. Some endpoints, including Electron,
16132
- expose existing tabs but reject Target.createTarget; on
16133
- those endpoints --url cannot be used. Conflicts with
16134
- --target.
16156
+ Target.createTarget. If an endpoint, including Electron,
16157
+ rejects Target.createTarget, capture adopts the matching
16158
+ existing page target that answers a screenshot. Conflicts
16159
+ with --target.
16135
16160
  --target <tab-id> Existing page tab to adopt. Resolves a full target id or
16136
16161
  an unambiguous prefix of at least four characters at the
16137
16162
  selected CDP endpoint; start fails if it does not resolve.
@@ -16277,6 +16302,10 @@ effects:
16277
16302
  const { findTabById: findTabById2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16278
16303
  return findTabById2(port, targetId);
16279
16304
  },
16305
+ async findResponsivePageTabByUrl(port, url) {
16306
+ const { findResponsivePageTabByUrl: findResponsivePageTabByUrl2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16307
+ return findResponsivePageTabByUrl2(port, url);
16308
+ },
16280
16309
  async closeTarget(port, targetId) {
16281
16310
  const { closeTarget: closeTarget2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16282
16311
  return closeTarget2(port, targetId);
@@ -267328,7 +267357,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267328
267357
  A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser \u2014 nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands \u2014 precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target.`;
267329
267358
  }
267330
267359
  function printVersion() {
267331
- const declared = "1.4.15";
267360
+ const declared = "1.4.17";
267332
267361
  if (declared) {
267333
267362
  console.log(declared);
267334
267363
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.15",
3
+ "version": "1.4.17",
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"
@@ -149,15 +149,17 @@ export async function findUnambiguousPageTabInPorts(
149
149
  return candidates[0] ?? null;
150
150
  }
151
151
 
152
- function normalizeUrlForMatch(value: string): { full: string; host: string; path: string } | null {
152
+ function normalizeUrlForMatch(value: string): { full: string; origin: string; path: string } | null {
153
153
  try {
154
154
  const url = new URL(value);
155
155
  const path = url.pathname.replace(/\/+$/, '') || '/';
156
+ const hostname = url.hostname.toLowerCase();
157
+ // Electron may expose a localhost page when the caller uses its equivalent IPv4 loopback URL.
158
+ const loopback = hostname === 'localhost' || hostname === '127.0.0.1';
159
+ const port = url.port || (url.protocol === 'http:' ? '80' : url.protocol === 'https:' ? '443' : '');
156
160
  return {
157
161
  full: `${url.origin}${path}${url.search}${url.hash}`.toLowerCase(),
158
- // `host` (not `hostname`): two local dev servers on the same loopback
159
- // address but different ports must not register as a host match.
160
- host: url.host.toLowerCase(),
162
+ origin: `${url.protocol}//${loopback ? 'loopback' : hostname}:${port}`,
161
163
  path: path.toLowerCase(),
162
164
  };
163
165
  } catch {
@@ -171,14 +173,11 @@ export function scoreTabUrlMatch(tabUrl: string, requestedUrl: string): number {
171
173
 
172
174
  if (tab && requested) {
173
175
  if (tab.full === requested.full) return 100;
174
- if (tab.host === requested.host && tab.path === requested.path) return 95;
175
- if (tab.host === requested.host && tab.path.startsWith(requested.path)) return 90;
176
- if (tab.host === requested.host && requested.path.startsWith(tab.path)) return 85;
177
- if (tab.host === requested.host) return 70;
178
- if (tab.full.includes(requested.full)) return 60;
179
- if (tab.full.includes(requested.host)) return 50;
180
- if (requested.full.includes(tab.full)) return 40;
181
- return 0;
176
+ if (tab.origin !== requested.origin) return 0;
177
+ if (tab.path === requested.path) return 95;
178
+ if (tab.path.startsWith(requested.path)) return 90;
179
+ if (requested.path.startsWith(tab.path)) return 85;
180
+ return 70;
182
181
  }
183
182
 
184
183
  const tabLower = tabUrl.toLowerCase();
@@ -205,6 +204,28 @@ export async function findTabByIdAcrossEndpoints(
205
204
  return findTabByIdInPorts(targetId, ports);
206
205
  }
207
206
 
207
+ export async function findResponsivePageTabByUrl(port: number, url: string): Promise<CDPTarget | null> {
208
+ const pages = (await listTargets(port)).filter((target) => target.type === 'page');
209
+ const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
210
+ const bestScore = Math.max(0, ...scores);
211
+ if (bestScore === 0) return null;
212
+
213
+ for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
214
+ if (!target.webSocketDebuggerUrl) continue;
215
+ const client = new CDPClient(target.webSocketDebuggerUrl);
216
+ try {
217
+ await client.waitReady();
218
+ await client.send('Page.captureScreenshot', { format: 'png' }, 5_000);
219
+ return target;
220
+ } catch {
221
+ // Electron can expose a page target that never answers screenshot requests.
222
+ } finally {
223
+ client.close();
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
208
229
  export async function findTabByUrlAcrossEndpoints(
209
230
  url: string,
210
231
  preferredPort?: number,
@@ -391,10 +391,10 @@ const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--
391
391
 
392
392
  input:
393
393
  --url <url> Absolute URL to open in a fresh tab through CDP's
394
- Target.createTarget. Some endpoints, including Electron,
395
- expose existing tabs but reject Target.createTarget; on
396
- those endpoints --url cannot be used. Conflicts with
397
- --target.
394
+ Target.createTarget. If an endpoint, including Electron,
395
+ rejects Target.createTarget, capture adopts the matching
396
+ existing page target that answers a screenshot. Conflicts
397
+ with --target.
398
398
  --target <tab-id> Existing page tab to adopt. Resolves a full target id or
399
399
  an unambiguous prefix of at least four characters at the
400
400
  selected CDP endpoint; start fails if it does not resolve.
@@ -563,6 +563,7 @@ export interface SessionStartWorld {
563
563
  detectCdpPort(): Promise<number | { port: number; app: string; selectionReason: string }>;
564
564
  openTab(port: number, url: string): Promise<CDPTarget>;
565
565
  findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
566
+ findResponsivePageTabByUrl?(port: number, url: string): Promise<CDPTarget | null>;
566
567
  closeTarget(port: number, targetId: string): Promise<void>;
567
568
  /** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
568
569
  awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
@@ -588,6 +589,10 @@ const productionStartWorld: SessionStartWorld = {
588
589
  const { findTabById } = await import('../cdp/targets.js');
589
590
  return findTabById(port, targetId);
590
591
  },
592
+ async findResponsivePageTabByUrl(port, url) {
593
+ const { findResponsivePageTabByUrl } = await import('../cdp/targets.js');
594
+ return findResponsivePageTabByUrl(port, url);
595
+ },
591
596
  async closeTarget(port, targetId) {
592
597
  const { closeTarget } = await import('../cdp/targets.js');
593
598
  return closeTarget(port, targetId);
@@ -692,6 +697,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
692
697
  let bridgeSocket: string | null = null;
693
698
  let bridgePid: number | null = null;
694
699
  let cdpPort: number | null = null;
700
+ let targetWasOpened = false;
695
701
  let selectedBrowser: string | null = null;
696
702
  let endpointSelectionReason: string | null = null;
697
703
 
@@ -723,22 +729,28 @@ async function start(parsed: ParsedArgs): Promise<void> {
723
729
  }
724
730
 
725
731
  if (url) {
732
+ let opened = false;
726
733
  try {
727
734
  target = await startWorld.openTab(cdpPort!, url);
735
+ opened = true;
736
+ targetWasOpened = true;
728
737
  } catch (error) {
729
738
  const message = error instanceof Error ? error.message : String(error);
730
- if (message.toLowerCase().includes('not supported')) {
739
+ if (!message.toLowerCase().includes('not supported')) throw error;
740
+ target = await startWorld.findResponsivePageTabByUrl?.(cdpPort!, url) ?? null;
741
+ if (!target) {
731
742
  throw worldFailure(
732
- `Target.createTarget is unsupported on port ${cdpPort}. Use an existing tab: \`capture tab list --port ${cdpPort}\`, then \`capture session start --target <target-id> --port ${cdpPort}\`.`,
743
+ `Target.createTarget is unsupported on port ${cdpPort}, and no matching existing page target answered a screenshot. Run \`capture tab list --port ${cdpPort}\` to inspect its targets.`,
733
744
  error,
734
745
  );
735
746
  }
736
- throw error;
737
747
  }
738
- const openedPort = cdpPort!;
739
- const openedTargetId = target.id;
740
- acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
741
- pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
748
+ if (opened) {
749
+ const openedPort = cdpPort!;
750
+ const openedTargetId = target.id;
751
+ acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
752
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
753
+ }
742
754
  }
743
755
 
744
756
  // Hold a CDP browser connection open for the session's lifetime so
@@ -778,7 +790,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
778
790
  }
779
791
 
780
792
  const rows: FactLine[] = [fact`bundle dir: ${dir}`];
781
- if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
793
+ if (target) rows.push(targetWasOpened ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
782
794
  if (cdpPort !== null) rows.push(
783
795
  parsed.portSource === 'env'
784
796
  ? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.`