@crouton-kit/capture 1.4.14 → 1.4.16

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
@@ -3769,6 +3769,14 @@ var init_client = __esm({
3769
3769
  }
3770
3770
  });
3771
3771
 
3772
+ // src/cdp/loopback.ts
3773
+ var CDP_LOOPBACK_HOST;
3774
+ var init_loopback = __esm({
3775
+ "src/cdp/loopback.ts"() {
3776
+ CDP_LOOPBACK_HOST = "127.0.0.1";
3777
+ }
3778
+ });
3779
+
3772
3780
  // src/cdp/detect.ts
3773
3781
  var detect_exports = {};
3774
3782
  __export(detect_exports, {
@@ -3784,7 +3792,7 @@ __export(detect_exports, {
3784
3792
  function httpGetJson(port, path48, timeoutMs) {
3785
3793
  return new Promise((resolve12) => {
3786
3794
  const req = http.get(
3787
- { host: "localhost", port, path: path48, timeout: timeoutMs },
3795
+ { host: CDP_LOOPBACK_HOST, port, path: path48, timeout: timeoutMs },
3788
3796
  (res) => {
3789
3797
  const status = res.statusCode ?? 0;
3790
3798
  if (status < 200 || status >= 300) {
@@ -3962,6 +3970,7 @@ var init_detect = __esm({
3962
3970
  "src/cdp/detect.ts"() {
3963
3971
  import_child_process = require("child_process");
3964
3972
  http = __toESM(require("http"));
3973
+ init_loopback();
3965
3974
  BROWSER_PATTERNS = {
3966
3975
  "company.thebrowser.browser": ["arc"],
3967
3976
  "com.google.chrome": ["chrome", "google chrome"],
@@ -5664,7 +5673,7 @@ async function reap(record5) {
5664
5673
  }
5665
5674
  async function endpointAnswers(port) {
5666
5675
  try {
5667
- const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
5676
+ const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
5668
5677
  signal: AbortSignal.timeout(ENDPOINT_PROBE_MS)
5669
5678
  });
5670
5679
  return response.ok;
@@ -5765,12 +5774,26 @@ function devToolsPort(profileDir) {
5765
5774
  return null;
5766
5775
  }
5767
5776
  }
5777
+ function assertPortUnbound(port) {
5778
+ try {
5779
+ const output = (0, import_node_child_process2.execFileSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-F", "n"], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
5780
+ if (output.trim()) {
5781
+ throw captureError("precondition", "browser_port_in_use", `received: port ${port}; expected: no TCP listener on that port. Capture refuses to launch because the endpoint would be ambiguous across loopback address families.`);
5782
+ }
5783
+ } catch (error4) {
5784
+ if (error4.status === 1) return;
5785
+ if (error4 instanceof CaptureError && error4.descriptor.code === "browser_port_in_use") throw error4;
5786
+ throw captureError("world", "browser_port_check_failed", `received: port ${port}; checking TCP listeners with lsof failed, so Capture cannot prove the port is unbound: ${error4 instanceof Error ? error4.message : String(error4)}`, error4);
5787
+ }
5788
+ }
5768
5789
  async function launchOwnedBrowser(options) {
5790
+ if (options.port !== void 0) assertPortUnbound(options.port);
5769
5791
  const executablePath = resolveBrowserExecutable(options.headless);
5770
5792
  const token2 = crypto2.randomBytes(8).toString("hex");
5771
5793
  const profileDir = ensurePrivateDir(profilePath(token2));
5772
5794
  const args = [
5773
5795
  `--remote-debugging-port=${options.port ?? 0}`,
5796
+ `--remote-debugging-address=${CDP_LOOPBACK_HOST}`,
5774
5797
  `--user-data-dir=${profileDir}`,
5775
5798
  "--no-first-run",
5776
5799
  "--no-default-browser-check",
@@ -5841,6 +5864,7 @@ var init_browser_process = __esm({
5841
5864
  os2 = __toESM(require("node:os"));
5842
5865
  path2 = __toESM(require("node:path"));
5843
5866
  init_errors();
5867
+ init_loopback();
5844
5868
  init_artifacts();
5845
5869
  IDLE_REAP_MS = 30 * 60 * 1e3;
5846
5870
  READY_TIMEOUT_MS = 15e3;
@@ -5886,6 +5910,7 @@ var targets_exports = {};
5886
5910
  __export(targets_exports, {
5887
5911
  AmbiguousPageTargetError: () => AmbiguousPageTargetError,
5888
5912
  closeTarget: () => closeTarget,
5913
+ findResponsivePageTabByUrl: () => findResponsivePageTabByUrl,
5889
5914
  findTab: () => findTab,
5890
5915
  findTabById: () => findTabById,
5891
5916
  findTabByIdAcrossEndpoints: () => findTabByIdAcrossEndpoints,
@@ -5899,7 +5924,7 @@ __export(targets_exports, {
5899
5924
  scoreTabUrlMatch: () => scoreTabUrlMatch
5900
5925
  });
5901
5926
  async function getBrowserClient(port) {
5902
- const versionResp = await fetch(`http://localhost:${port}/json/version`);
5927
+ const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
5903
5928
  if (!versionResp.ok) {
5904
5929
  throw new Error(
5905
5930
  `Failed to connect to CDP on port ${port}: ${versionResp.status}`
@@ -5911,10 +5936,12 @@ async function getBrowserClient(port) {
5911
5936
  `Port ${port} answers CDP's /json/version but has no browser-level WebSocket debugger URL \u2014 it's not a real browser (e.g. a Node/workerd inspector), so it can't host tabs. Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`
5912
5937
  );
5913
5938
  }
5914
- const client = new CDPClient(version.webSocketDebuggerUrl);
5939
+ const browserWsUrl = new URL(version.webSocketDebuggerUrl);
5940
+ browserWsUrl.hostname = CDP_LOOPBACK_HOST;
5941
+ const client = new CDPClient(browserWsUrl.toString());
5915
5942
  await client.waitReady();
5916
5943
  touchOwnedBrowser(port);
5917
- return { client, browserWsUrl: version.webSocketDebuggerUrl };
5944
+ return { client, browserWsUrl: browserWsUrl.toString() };
5918
5945
  }
5919
5946
  async function listTargets(port) {
5920
5947
  const { client } = await getBrowserClient(port);
@@ -5926,7 +5953,7 @@ async function listTargets(port) {
5926
5953
  title: t.title,
5927
5954
  url: t.url,
5928
5955
  type: t.type,
5929
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`
5956
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`
5930
5957
  }));
5931
5958
  } finally {
5932
5959
  client.close();
@@ -5985,7 +6012,9 @@ function normalizeUrlForMatch(value) {
5985
6012
  const path48 = url.pathname.replace(/\/+$/, "") || "/";
5986
6013
  return {
5987
6014
  full: `${url.origin}${path48}${url.search}${url.hash}`.toLowerCase(),
5988
- host: url.hostname.toLowerCase(),
6015
+ // `host` (not `hostname`): two local dev servers on the same loopback
6016
+ // address but different ports must not register as a host match.
6017
+ host: url.host.toLowerCase(),
5989
6018
  path: path48.toLowerCase()
5990
6019
  };
5991
6020
  } catch {
@@ -6021,6 +6050,25 @@ async function findTabByIdAcrossEndpoints(targetId, preferredPort) {
6021
6050
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
6022
6051
  return findTabByIdInPorts(targetId, ports);
6023
6052
  }
6053
+ async function findResponsivePageTabByUrl(port, url) {
6054
+ const pages = (await listTargets(port)).filter((target) => target.type === "page");
6055
+ const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
6056
+ const bestScore = Math.max(0, ...scores);
6057
+ if (bestScore === 0) return null;
6058
+ for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
6059
+ if (!target.webSocketDebuggerUrl) continue;
6060
+ const client = new CDPClient(target.webSocketDebuggerUrl);
6061
+ try {
6062
+ await client.waitReady();
6063
+ await client.send("Page.captureScreenshot", { format: "png" }, 5e3);
6064
+ return target;
6065
+ } catch {
6066
+ } finally {
6067
+ client.close();
6068
+ }
6069
+ }
6070
+ return null;
6071
+ }
6024
6072
  async function findTabByUrlAcrossEndpoints(url, preferredPort) {
6025
6073
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
6026
6074
  let best = null;
@@ -6071,7 +6119,7 @@ async function openTab(port, url) {
6071
6119
  title: "",
6072
6120
  url,
6073
6121
  type: "page",
6074
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${targetId}`
6122
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`
6075
6123
  };
6076
6124
  } finally {
6077
6125
  client.close();
@@ -6081,6 +6129,7 @@ var AmbiguousPageTargetError;
6081
6129
  var init_targets = __esm({
6082
6130
  "src/cdp/targets.ts"() {
6083
6131
  init_client();
6132
+ init_loopback();
6084
6133
  init_detect();
6085
6134
  init_browser_process();
6086
6135
  AmbiguousPageTargetError = class extends Error {
@@ -10512,7 +10561,7 @@ function validateHarAppendBatch(value, source) {
10512
10561
  return { entries, incompleteLifecycles };
10513
10562
  }
10514
10563
  function captureVersion() {
10515
- const version = "1.4.14";
10564
+ const version = "1.4.16";
10516
10565
  return typeof version === "string" && version.length > 0 ? version : "development";
10517
10566
  }
10518
10567
  function token() {
@@ -12614,7 +12663,7 @@ function wallTime(value) {
12614
12663
  }
12615
12664
  }
12616
12665
  function captureVersion2() {
12617
- const version = "1.4.14";
12666
+ const version = "1.4.16";
12618
12667
  return typeof version === "string" && version.length > 0 ? version : "development";
12619
12668
  }
12620
12669
  function deepFreeze(value) {
@@ -15111,6 +15160,7 @@ async function start(parsed) {
15111
15160
  let bridgeSocket = null;
15112
15161
  let bridgePid = null;
15113
15162
  let cdpPort = null;
15163
+ let targetWasOpened = false;
15114
15164
  let selectedBrowser = null;
15115
15165
  let endpointSelectionReason = null;
15116
15166
  try {
@@ -15137,22 +15187,28 @@ async function start(parsed) {
15137
15187
  }
15138
15188
  }
15139
15189
  if (url) {
15190
+ let opened = false;
15140
15191
  try {
15141
15192
  target = await startWorld.openTab(cdpPort, url);
15193
+ opened = true;
15194
+ targetWasOpened = true;
15142
15195
  } catch (error4) {
15143
15196
  const message = error4 instanceof Error ? error4.message : String(error4);
15144
- if (message.toLowerCase().includes("not supported")) {
15197
+ if (!message.toLowerCase().includes("not supported")) throw error4;
15198
+ target = await startWorld.findResponsivePageTabByUrl?.(cdpPort, url) ?? null;
15199
+ if (!target) {
15145
15200
  throw worldFailure(
15146
- `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}\`.`,
15201
+ `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.`,
15147
15202
  error4
15148
15203
  );
15149
15204
  }
15150
- throw error4;
15151
15205
  }
15152
- const openedPort = cdpPort;
15153
- const openedTargetId = target.id;
15154
- acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15155
- pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15206
+ if (opened) {
15207
+ const openedPort = cdpPort;
15208
+ const openedTargetId = target.id;
15209
+ acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15210
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15211
+ }
15156
15212
  }
15157
15213
  if (hold) {
15158
15214
  const bridge = await startWorld.startBridge(dir, cdpPort);
@@ -15182,7 +15238,7 @@ async function start(parsed) {
15182
15238
  return;
15183
15239
  }
15184
15240
  const rows = [fact`bundle dir: ${dir}`];
15185
- if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
15241
+ if (target) rows.push(targetWasOpened ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
15186
15242
  if (cdpPort !== null) rows.push(
15187
15243
  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.`
15188
15244
  );
@@ -15409,6 +15465,7 @@ function responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotoni
15409
15465
  return Number.isNaN(end.getTime()) ? null : end.toISOString();
15410
15466
  }
15411
15467
  function completedTiming(e) {
15468
+ if ("_resourceType" in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
15412
15469
  const { clocks, terminal: terminal2 } = e._capture;
15413
15470
  if (terminal2.kind === "failed") {
15414
15471
  return fact`duration ${e.time} ms — response incomplete: failed`;
@@ -16098,10 +16155,10 @@ use when collecting or reading the traffic, logs, and artifacts of scoped work a
16098
16155
 
16099
16156
  input:
16100
16157
  --url <url> Absolute URL to open in a fresh tab through CDP's
16101
- Target.createTarget. Some endpoints, including Electron,
16102
- expose existing tabs but reject Target.createTarget; on
16103
- those endpoints --url cannot be used. Conflicts with
16104
- --target.
16158
+ Target.createTarget. If an endpoint, including Electron,
16159
+ rejects Target.createTarget, capture adopts the matching
16160
+ existing page target that answers a screenshot. Conflicts
16161
+ with --target.
16105
16162
  --target <tab-id> Existing page tab to adopt. Resolves a full target id or
16106
16163
  an unambiguous prefix of at least four characters at the
16107
16164
  selected CDP endpoint; start fails if it does not resolve.
@@ -16247,6 +16304,10 @@ effects:
16247
16304
  const { findTabById: findTabById2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16248
16305
  return findTabById2(port, targetId);
16249
16306
  },
16307
+ async findResponsivePageTabByUrl(port, url) {
16308
+ const { findResponsivePageTabByUrl: findResponsivePageTabByUrl2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16309
+ return findResponsivePageTabByUrl2(port, url);
16310
+ },
16250
16311
  async closeTarget(port, targetId) {
16251
16312
  const { closeTarget: closeTarget2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
16252
16313
  return closeTarget2(port, targetId);
@@ -229755,6 +229816,7 @@ async function cmdTabLaunch(parsed, _args) {
229755
229816
  console.log(USAGE9);
229756
229817
  return;
229757
229818
  }
229819
+ if (parsed.port !== void 0) assertPortUnbound(parsed.port);
229758
229820
  const swept = await sweepOwnedBrowsers();
229759
229821
  const browser = await launchOwnedBrowser({
229760
229822
  url: parsed.url,
@@ -239412,7 +239474,7 @@ function artifactEntries(dir, artifacts, listCrops) {
239412
239474
  if (!listCrops && name.startsWith("crops/")) continue;
239413
239475
  entries.push({
239414
239476
  name,
239415
- ...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements` } : {}
239477
+ ...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements at .elements[]` } : {}
239416
239478
  });
239417
239479
  }
239418
239480
  if (!listCrops && crops.length) entries.push({ name: "crops/", note: `${crops.length} crops; pass --list-crops for paths` });
@@ -243965,7 +244027,7 @@ static page measurement \u2014 \`snap\` waits for the page to settle, then recor
243965
244027
  use when a claim depends on what is rendered right now: element geometry, overflow, clipping, stacking, computed style and where it came from, text and contrast, focus order, scroll containers, paint coverage, or the difference between two snapshots
243966
244028
  </command>`;
243967
244029
  MEASURE_USAGE = `<command name="measure" description="enriched snapshot substrate and read-only queries">
243968
- <model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 \u2014 a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff.</model>
244030
+ <model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 \u2014 a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff. \`explain\` (and \`map paint\`) take the snapshot as \`<snap>\` positionally and name the element with a required \`--selector\`, unlike every other leaf's plain \`[url|snap]\` target.</model>
243969
244031
  <subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
243970
244032
  <subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
243971
244033
  <subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
@@ -243976,7 +244038,7 @@ use when a claim depends on what is rendered right now: element geometry, overfl
243976
244038
  <subcommand name="map" description="one snapshot facet" whenToUse="Use to read focus, scroll, layer, accessibility, or paint facts from a snapshot."/>
243977
244039
  </command>`;
243978
244040
  MEASURE_MAP_USAGE = `<command name="map" description="one facet of a snapshot's substrate">
243979
- <model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot and reads it without browser driving.</model>
244041
+ <model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot (\`<snap>\`, no URL) plus a required \`--selector\` naming the element, and reads it without browser driving.</model>
243980
244042
  <subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
243981
244043
  <subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
243982
244044
  <subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
@@ -266843,7 +266905,7 @@ async function releaseTracing(reservation) {
266843
266905
  async function pageForSessionTarget(port, targetId) {
266844
266906
  const lighthouseRequire = (0, import_node_module.createRequire)(require.resolve("lighthouse"));
266845
266907
  const puppeteer = lighthouseRequire("puppeteer-core");
266846
- const browser = await puppeteer.connect({ browserURL: `http://localhost:${port}`, defaultViewport: null });
266908
+ const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
266847
266909
  try {
266848
266910
  for (const page of await browser.pages()) {
266849
266911
  const session = await page.target().createCDPSession();
@@ -267050,6 +267112,7 @@ var init_lighthouse = __esm({
267050
267112
  import_node_module = require("node:module");
267051
267113
  init_detect();
267052
267114
  init_errors();
267115
+ init_loopback();
267053
267116
  init_selection();
267054
267117
  init_artifacts();
267055
267118
  init_session_context();
@@ -267296,7 +267359,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267296
267359
  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.`;
267297
267360
  }
267298
267361
  function printVersion() {
267299
- const declared = "1.4.14";
267362
+ const declared = "1.4.16";
267300
267363
  if (declared) {
267301
267364
  console.log(declared);
267302
267365
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.14",
3
+ "version": "1.4.16",
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"
@@ -35,12 +35,13 @@
35
35
  * rather than living until reboot.
36
36
  */
37
37
 
38
- import { spawn } from 'node:child_process';
38
+ import { execFileSync, spawn } from 'node:child_process';
39
39
  import * as crypto from 'node:crypto';
40
40
  import * as fs from 'node:fs';
41
41
  import * as os from 'node:os';
42
42
  import * as path from 'node:path';
43
- import { captureError } from '../errors.js';
43
+ import { CaptureError, captureError } from '../errors.js';
44
+ import { CDP_LOOPBACK_HOST } from './loopback.js';
44
45
  import {
45
46
  CAPTURE_ROOT,
46
47
  assertUnderCaptureRoot,
@@ -230,7 +231,7 @@ async function reap(record: OwnedBrowser): Promise<void> {
230
231
 
231
232
  async function endpointAnswers(port: number): Promise<boolean> {
232
233
  try {
233
- const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
234
+ const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
234
235
  signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
235
236
  });
236
237
  return response.ok;
@@ -421,6 +422,23 @@ function devToolsPort(profileDir: string): number | null {
421
422
  }
422
423
  }
423
424
 
425
+ /** Refuses an explicit debugging port already listened on through either
426
+ * address family. `lsof` exits 1 when it found no listener; every other
427
+ * failure is a failed precondition check, never permission to launch into an
428
+ * endpoint whose identity capture cannot establish. */
429
+ export function assertPortUnbound(port: number): void {
430
+ try {
431
+ const output = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'n'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
432
+ if (output.trim()) {
433
+ throw captureError('precondition', 'browser_port_in_use', `received: port ${port}; expected: no TCP listener on that port. Capture refuses to launch because the endpoint would be ambiguous across loopback address families.`);
434
+ }
435
+ } catch (error) {
436
+ if ((error as NodeJS.ErrnoException).status === 1) return;
437
+ if (error instanceof CaptureError && error.descriptor.code === 'browser_port_in_use') throw error;
438
+ throw captureError('world', 'browser_port_check_failed', `received: port ${port}; checking TCP listeners with lsof failed, so Capture cannot prove the port is unbound: ${error instanceof Error ? error.message : String(error)}`, error);
439
+ }
440
+ }
441
+
424
442
  export interface LaunchOptions {
425
443
  /** First page to open; `about:blank` when unset. */
426
444
  url?: string;
@@ -436,11 +454,13 @@ export interface LaunchOptions {
436
454
  * `startBridge()` applies to the bridge process it spawns.
437
455
  */
438
456
  export async function launchOwnedBrowser(options: LaunchOptions): Promise<OwnedBrowser> {
457
+ if (options.port !== undefined) assertPortUnbound(options.port);
439
458
  const executablePath = resolveBrowserExecutable(options.headless);
440
459
  const token = crypto.randomBytes(8).toString('hex');
441
460
  const profileDir = ensurePrivateDir(profilePath(token));
442
461
  const args = [
443
462
  `--remote-debugging-port=${options.port ?? 0}`,
463
+ `--remote-debugging-address=${CDP_LOOPBACK_HOST}`,
444
464
  `--user-data-dir=${profileDir}`,
445
465
  '--no-first-run',
446
466
  '--no-default-browser-check',
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import { detectCdpPort } from '../detect.js';
6
6
  import { captureError } from '../../errors.js';
7
7
  import { type ParsedArgs } from '../types.js';
8
+ import { CDP_LOOPBACK_HOST } from '../loopback.js';
8
9
  import { selectRecords } from '../../output/selection.js';
9
10
  import { CAPTURE_ROOT, ensurePrivateDir, processPidBirthProvider, registerArtifactRoot, writeJsonPrivate, writePrivateFile } from '../../session/artifacts.js';
10
11
  import { getActiveSession } from '../../session-context.js';
@@ -191,7 +192,7 @@ async function releaseTracing(reservation: TracingReservation): Promise<void> {
191
192
  async function pageForSessionTarget(port: number, targetId: string): Promise<{ browser: PuppeteerBrowser; page: LighthousePage }> {
192
193
  const lighthouseRequire = createRequire(require.resolve('lighthouse'));
193
194
  const puppeteer = lighthouseRequire('puppeteer-core') as PuppeteerModule;
194
- const browser = await puppeteer.connect({ browserURL: `http://localhost:${port}`, defaultViewport: null });
195
+ const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
195
196
  try {
196
197
  for (const page of await browser.pages()) {
197
198
  const session = await page.target().createCDPSession();
@@ -29,7 +29,7 @@ use when a claim depends on what is rendered right now: element geometry, overfl
29
29
  </command>`;
30
30
 
31
31
  export const MEASURE_USAGE = `<command name="measure" description="enriched snapshot substrate and read-only queries">
32
- <model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 — a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff.</model>
32
+ <model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 — a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff. \`explain\` (and \`map paint\`) take the snapshot as \`<snap>\` positionally and name the element with a required \`--selector\`, unlike every other leaf's plain \`[url|snap]\` target.</model>
33
33
  <subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
34
34
  <subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
35
35
  <subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
@@ -41,7 +41,7 @@ export const MEASURE_USAGE = `<command name="measure" description="enriched snap
41
41
  </command>`;
42
42
 
43
43
  export const MEASURE_MAP_USAGE = `<command name="map" description="one facet of a snapshot's substrate">
44
- <model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot and reads it without browser driving.</model>
44
+ <model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot (\`<snap>\`, no URL) plus a required \`--selector\` naming the element, and reads it without browser driving.</model>
45
45
  <subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
46
46
  <subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
47
47
  <subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
@@ -83,7 +83,7 @@ function artifactEntries(dir: string, artifacts: readonly string[], listCrops: b
83
83
  if (!listCrops && name.startsWith('crops/')) continue;
84
84
  entries.push({
85
85
  name,
86
- ...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements` } : {}),
86
+ ...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements at .elements[]` } : {}),
87
87
  });
88
88
  }
89
89
  if (!listCrops && crops.length) entries.push({ name: 'crops/', note: `${crops.length} crops; pass --list-crops for paths` });
@@ -9,7 +9,7 @@
9
9
  * Ownership, registry, and reaping all live in `../../browser-process.ts`; this
10
10
  * leaf is the surface over them.
11
11
  */
12
- import { launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
12
+ import { assertPortUnbound, launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
13
13
  import { type ParsedArgs } from '../../types.js';
14
14
  import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
15
15
 
@@ -54,6 +54,9 @@ export async function cmdTabLaunch(parsed: ParsedArgs, _args: string[]): Promise
54
54
  return;
55
55
  }
56
56
 
57
+ // An explicit port must be checked before a sweep can reap an owned listener
58
+ // on it: this invocation must reject ambiguity, never create it by replacement.
59
+ if (parsed.port !== undefined) assertPortUnbound(parsed.port);
57
60
  const swept = await sweepOwnedBrowsers();
58
61
  const browser = await launchOwnedBrowser({
59
62
  url: parsed.url,
package/src/cdp/detect.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  import * as http from 'http';
3
+ import { CDP_LOOPBACK_HOST } from './loopback.js';
3
4
 
4
5
  /**
5
6
  * A CDP-endpoint JSON probe that CANNOT leak a socket handle. Node's built-in
@@ -21,7 +22,7 @@ function httpGetJson(
21
22
  ): Promise<unknown | null> {
22
23
  return new Promise((resolve) => {
23
24
  const req = http.get(
24
- { host: 'localhost', port, path, timeout: timeoutMs },
25
+ { host: CDP_LOOPBACK_HOST, port, path, timeout: timeoutMs },
25
26
  (res) => {
26
27
  const status = res.statusCode ?? 0;
27
28
  if (status < 200 || status >= 300) {
@@ -0,0 +1,4 @@
1
+ /** Capture addresses CDP endpoints on one literal loopback address, never the
2
+ * hostname `localhost`: the hostname can resolve to either address family,
3
+ * which makes two listeners sharing a port an ambiguous endpoint. */
4
+ export const CDP_LOOPBACK_HOST = '127.0.0.1';
@@ -1,4 +1,5 @@
1
1
  import { CDPClient } from './client.js';
2
+ import { CDP_LOOPBACK_HOST } from './loopback.js';
2
3
  import { detectCdpPortsAsync } from './detect.js';
3
4
  import { touchOwnedBrowser } from './browser-process.js';
4
5
  import { type CDPTarget } from './types.js';
@@ -13,7 +14,7 @@ import { type CDPTarget } from './types.js';
13
14
  export async function getBrowserClient(
14
15
  port: number,
15
16
  ): Promise<{ client: CDPClient; browserWsUrl: string }> {
16
- const versionResp = await fetch(`http://localhost:${port}/json/version`);
17
+ const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
17
18
  if (!versionResp.ok) {
18
19
  throw new Error(
19
20
  `Failed to connect to CDP on port ${port}: ${versionResp.status}`,
@@ -29,13 +30,15 @@ export async function getBrowserClient(
29
30
  `Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`,
30
31
  );
31
32
  }
32
- const client = new CDPClient(version.webSocketDebuggerUrl);
33
+ const browserWsUrl = new URL(version.webSocketDebuggerUrl);
34
+ browserWsUrl.hostname = CDP_LOOPBACK_HOST;
35
+ const client = new CDPClient(browserWsUrl.toString());
33
36
  await client.waitReady();
34
37
  // Every browser-level connection funnels through here, which makes this the
35
38
  // one place that can tell capture's idle sweep the browser is still in use.
36
39
  // A port capture did not launch has no registry record and this is a no-op.
37
40
  touchOwnedBrowser(port);
38
- return { client, browserWsUrl: version.webSocketDebuggerUrl };
41
+ return { client, browserWsUrl: browserWsUrl.toString() };
39
42
  }
40
43
 
41
44
  export async function listTargets(port: number): Promise<CDPTarget[]> {
@@ -59,7 +62,7 @@ export async function listTargets(port: number): Promise<CDPTarget[]> {
59
62
  title: t.title,
60
63
  url: t.url,
61
64
  type: t.type,
62
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`,
65
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`,
63
66
  }));
64
67
  } finally {
65
68
  client.close();
@@ -152,7 +155,9 @@ function normalizeUrlForMatch(value: string): { full: string; host: string; path
152
155
  const path = url.pathname.replace(/\/+$/, '') || '/';
153
156
  return {
154
157
  full: `${url.origin}${path}${url.search}${url.hash}`.toLowerCase(),
155
- host: url.hostname.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(),
156
161
  path: path.toLowerCase(),
157
162
  };
158
163
  } catch {
@@ -200,6 +205,28 @@ export async function findTabByIdAcrossEndpoints(
200
205
  return findTabByIdInPorts(targetId, ports);
201
206
  }
202
207
 
208
+ export async function findResponsivePageTabByUrl(port: number, url: string): Promise<CDPTarget | null> {
209
+ const pages = (await listTargets(port)).filter((target) => target.type === 'page');
210
+ const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
211
+ const bestScore = Math.max(0, ...scores);
212
+ if (bestScore === 0) return null;
213
+
214
+ for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
215
+ if (!target.webSocketDebuggerUrl) continue;
216
+ const client = new CDPClient(target.webSocketDebuggerUrl);
217
+ try {
218
+ await client.waitReady();
219
+ await client.send('Page.captureScreenshot', { format: 'png' }, 5_000);
220
+ return target;
221
+ } catch {
222
+ // Electron can expose a page target that never answers screenshot requests.
223
+ } finally {
224
+ client.close();
225
+ }
226
+ }
227
+ return null;
228
+ }
229
+
203
230
  export async function findTabByUrlAcrossEndpoints(
204
231
  url: string,
205
232
  preferredPort?: number,
@@ -276,7 +303,7 @@ export async function openTab(port: number, url: string): Promise<CDPTarget> {
276
303
  title: '',
277
304
  url,
278
305
  type: 'page',
279
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${targetId}`,
306
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`,
280
307
  };
281
308
  } finally {
282
309
  client.close();
@@ -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.`
@@ -1103,6 +1115,7 @@ function responseEndDateTime(
1103
1115
  }
1104
1116
 
1105
1117
  function completedTiming(e: HAREntry): FactLine {
1118
+ if ('_resourceType' in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
1106
1119
  const { clocks, terminal } = e._capture;
1107
1120
  if (terminal.kind === 'failed') {
1108
1121
  return fact`duration ${e.time} ms — response incomplete: failed`;