@crouton-kit/capture 1.4.13 → 1.4.15

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;
@@ -5899,7 +5923,7 @@ __export(targets_exports, {
5899
5923
  scoreTabUrlMatch: () => scoreTabUrlMatch
5900
5924
  });
5901
5925
  async function getBrowserClient(port) {
5902
- const versionResp = await fetch(`http://localhost:${port}/json/version`);
5926
+ const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
5903
5927
  if (!versionResp.ok) {
5904
5928
  throw new Error(
5905
5929
  `Failed to connect to CDP on port ${port}: ${versionResp.status}`
@@ -5911,10 +5935,12 @@ async function getBrowserClient(port) {
5911
5935
  `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
5936
  );
5913
5937
  }
5914
- const client = new CDPClient(version.webSocketDebuggerUrl);
5938
+ const browserWsUrl = new URL(version.webSocketDebuggerUrl);
5939
+ browserWsUrl.hostname = CDP_LOOPBACK_HOST;
5940
+ const client = new CDPClient(browserWsUrl.toString());
5915
5941
  await client.waitReady();
5916
5942
  touchOwnedBrowser(port);
5917
- return { client, browserWsUrl: version.webSocketDebuggerUrl };
5943
+ return { client, browserWsUrl: browserWsUrl.toString() };
5918
5944
  }
5919
5945
  async function listTargets(port) {
5920
5946
  const { client } = await getBrowserClient(port);
@@ -5926,7 +5952,7 @@ async function listTargets(port) {
5926
5952
  title: t.title,
5927
5953
  url: t.url,
5928
5954
  type: t.type,
5929
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${t.targetId}`
5955
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`
5930
5956
  }));
5931
5957
  } finally {
5932
5958
  client.close();
@@ -5985,7 +6011,9 @@ function normalizeUrlForMatch(value) {
5985
6011
  const path48 = url.pathname.replace(/\/+$/, "") || "/";
5986
6012
  return {
5987
6013
  full: `${url.origin}${path48}${url.search}${url.hash}`.toLowerCase(),
5988
- host: url.hostname.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(),
5989
6017
  path: path48.toLowerCase()
5990
6018
  };
5991
6019
  } catch {
@@ -6071,7 +6099,7 @@ async function openTab(port, url) {
6071
6099
  title: "",
6072
6100
  url,
6073
6101
  type: "page",
6074
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${targetId}`
6102
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`
6075
6103
  };
6076
6104
  } finally {
6077
6105
  client.close();
@@ -6081,6 +6109,7 @@ var AmbiguousPageTargetError;
6081
6109
  var init_targets = __esm({
6082
6110
  "src/cdp/targets.ts"() {
6083
6111
  init_client();
6112
+ init_loopback();
6084
6113
  init_detect();
6085
6114
  init_browser_process();
6086
6115
  AmbiguousPageTargetError = class extends Error {
@@ -10512,7 +10541,7 @@ function validateHarAppendBatch(value, source) {
10512
10541
  return { entries, incompleteLifecycles };
10513
10542
  }
10514
10543
  function captureVersion() {
10515
- const version = "1.4.13";
10544
+ const version = "1.4.15";
10516
10545
  return typeof version === "string" && version.length > 0 ? version : "development";
10517
10546
  }
10518
10547
  function token() {
@@ -12614,7 +12643,7 @@ function wallTime(value) {
12614
12643
  }
12615
12644
  }
12616
12645
  function captureVersion2() {
12617
- const version = "1.4.13";
12646
+ const version = "1.4.15";
12618
12647
  return typeof version === "string" && version.length > 0 ? version : "development";
12619
12648
  }
12620
12649
  function deepFreeze(value) {
@@ -15409,6 +15438,7 @@ function responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotoni
15409
15438
  return Number.isNaN(end.getTime()) ? null : end.toISOString();
15410
15439
  }
15411
15440
  function completedTiming(e) {
15441
+ if ("_resourceType" in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
15412
15442
  const { clocks, terminal: terminal2 } = e._capture;
15413
15443
  if (terminal2.kind === "failed") {
15414
15444
  return fact`duration ${e.time} ms — response incomplete: failed`;
@@ -16091,7 +16121,7 @@ var init_commands = __esm({
16091
16121
  };
16092
16122
  SESSION_TAB_ATTACH_TIMEOUT_MS = 5e3;
16093
16123
  COMMAND_BLOCK = `<command name="session">
16094
- the artifact container \u2014 a session records HAR and bundles every artifact; it may open or adopt a tab, and once it has one, every command auto-targets it
16124
+ the work's evidence bundle \u2014 \`start\` opens or adopts a tab, records its network traffic (HAR) and external logs, and collects every artifact into one directory; while running, every command auto-targets its tab
16095
16125
  use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
16096
16126
  </command>`;
16097
16127
  START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>] \u2014 open or adopt a tab, record HAR, and set the active capture context.
@@ -229755,6 +229785,7 @@ async function cmdTabLaunch(parsed, _args) {
229755
229785
  console.log(USAGE9);
229756
229786
  return;
229757
229787
  }
229788
+ if (parsed.port !== void 0) assertPortUnbound(parsed.port);
229758
229789
  const swept = await sweepOwnedBrowsers();
229759
229790
  const browser = await launchOwnedBrowser({
229760
229791
  url: parsed.url,
@@ -230686,7 +230717,7 @@ var init_tab = __esm({
230686
230717
  init_network();
230687
230718
  init_mock();
230688
230719
  COMMAND_BLOCK3 = `<command name="tab">
230689
- browser and tab plumbing \u2014 the existence of a browser capture owns, endpoint/tab discovery, tab lifecycle, and the conditions the tab's network runs under (connectivity, mocked responses)
230720
+ browser and tab plumbing \u2014 launch or quit a browser capture owns, discover endpoints and tabs, open/close/reset tabs, and set the tab's network conditions (throttling, offline, mocked responses)
230690
230721
  use when starting, finding, opening, closing, or resetting a browser tab, or changing its network conditions
230691
230722
  </command>`;
230692
230723
  TAB_USAGE = `<command name="tab" description="browser and tab lifecycle">
@@ -239412,7 +239443,7 @@ function artifactEntries(dir, artifacts, listCrops) {
239412
239443
  if (!listCrops && name.startsWith("crops/")) continue;
239413
239444
  entries.push({
239414
239445
  name,
239415
- ...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements` } : {}
239446
+ ...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements at .elements[]` } : {}
239416
239447
  });
239417
239448
  }
239418
239449
  if (!listCrops && crops.length) entries.push({ name: "crops/", note: `${crops.length} crops; pass --list-crops for paths` });
@@ -243961,11 +243992,11 @@ var init_measure = __esm({
243961
243992
  init_map_ax2();
243962
243993
  init_map_paint();
243963
243994
  COMMAND_BLOCK6 = `<command name="measure">
243964
- the settled-snapshot substrate \u2014 static facts at one moment; \`snap\` writes the substrate, every other leaf is a read-only query over it
243965
- use for static rendered-page facts from a settled snapshot: layout, content, cascade provenance, targetability, and snapshot diffs
243995
+ static page measurement \u2014 \`snap\` waits for the page to settle, then records its layout, styles, text, and accessibility into one artifact; every other leaf answers questions from that artifact without re-driving the browser
243996
+ 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
243997
  </command>`;
243967
243998
  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>
243999
+ <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
244000
  <subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
243970
244001
  <subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
243971
244002
  <subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
@@ -243976,7 +244007,7 @@ use for static rendered-page facts from a settled snapshot: layout, content, cas
243976
244007
  <subcommand name="map" description="one snapshot facet" whenToUse="Use to read focus, scroll, layer, accessibility, or paint facts from a snapshot."/>
243977
244008
  </command>`;
243978
244009
  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>
244010
+ <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
244011
  <subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
243981
244012
  <subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
243982
244013
  <subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
@@ -245996,8 +246027,8 @@ var init_motion2 = __esm({
245996
246027
  init_jank2();
245997
246028
  init_response2();
245998
246029
  COMMAND_BLOCK7 = `<command name="motion">
245999
- the screencast-recording substrate \u2014 what changed on screen across frames; \`rec\` writes the recording, every other leaf is a read-only query over it
246000
- use for per-element frame geometry, changed regions, and input-to-settle timing; use \`perf\` instead for trace-event timing
246030
+ screen recording \u2014 \`rec\` screencasts the tab through an interaction; every other leaf answers what moved, where, and how long it took from that recording's frames
246031
+ use when a claim depends on change over time: element motion across frames, which screen regions changed, layout shifts and long tasks, or the time from an input to a settled screen; for trace-event timing use \`perf\` instead
246001
246032
  </command>`;
246002
246033
  MOTION_USAGE = `<command name="motion" description="recording lifecycle and read-only queries over a finalized recording">
246003
246034
  <model>\`rec\` drives and records the browser, one-shot or composed across intervening commands; every other leaf below reads the finalized recording artifact. Findings exit 0 \u2014 a report, not a failure; input and precondition errors exit 1. No leaf accepts --gate.</model>
@@ -265772,8 +265803,8 @@ var init_perf = __esm({
265772
265803
  init_vitals();
265773
265804
  init_insights2();
265774
265805
  COMMAND_BLOCK8 = `<command name="perf">
265775
- the Chrome performance-trace substrate \u2014 \`trace\` records one, \`vitals\` and \`insights\` are read-only queries over it
265776
- use for Core Web Vitals and DevTools insights over a page load or one interaction, timed from trace events
265806
+ Chrome performance tracing \u2014 \`trace\` records a DevTools performance trace over a page load or an interaction; \`vitals\` and \`insights\` read it back without re-running anything
265807
+ use when a claim depends on runtime performance: LCP, INP, and CLS with attribution, or DevTools' computed insights such as render-blocking requests, forced reflow, and layout-shift culprits
265777
265808
  </command>`;
265778
265809
  HELP7 = `<command name="perf" description="the Chrome performance-trace substrate">
265779
265810
  <model>\`trace\` drives (and records) the browser, one-shot or composed across intervening commands; the other leaves are cheap reads over the finalized trace artifact. Findings exit 0 \u2014 a report, not a failure; input/precondition errors exit 1. No leaf accepts --gate.</model>
@@ -266726,8 +266757,8 @@ var init_heap2 = __esm({
266726
266757
  init_retainers();
266727
266758
  init_diff4();
266728
266759
  COMMAND_BLOCK9 = `<command name="heap">
266729
- the tab's JavaScript heap \u2014 \`snapshot\` records the heap as a V8 snapshot, every other leaf is a read-only graph query over it
266730
- use for JavaScript-heap retention: constructor totals, retained sizes, retaining paths, duplicate strings, and before/after comparison
266760
+ JavaScript memory \u2014 \`snapshot\` writes the tab's V8 heap snapshot; every other leaf queries that object graph without touching the browser
266761
+ use when a claim depends on memory: what the heap holds per constructor, which objects exist, what keeps one alive, duplicated strings, or what grew between two snapshots when hunting a leak
266731
266762
  </command>`;
266732
266763
  HELP13 = `<command name="heap" description="the V8 heap-snapshot substrate">
266733
266764
  <model>\`snapshot\` drives the browser; every other leaf is a read of the finalized snapshot artifact and never touches Chrome. Retained sizes are dominator-subtree sums of Chrome's own self_size. Findings exit 0; input/precondition errors exit 1. No leaf accepts --gate.</model>
@@ -266843,7 +266874,7 @@ async function releaseTracing(reservation) {
266843
266874
  async function pageForSessionTarget(port, targetId) {
266844
266875
  const lighthouseRequire = (0, import_node_module.createRequire)(require.resolve("lighthouse"));
266845
266876
  const puppeteer = lighthouseRequire("puppeteer-core");
266846
- const browser = await puppeteer.connect({ browserURL: `http://localhost:${port}`, defaultViewport: null });
266877
+ const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
266847
266878
  try {
266848
266879
  for (const page of await browser.pages()) {
266849
266880
  const session = await page.target().createCDPSession();
@@ -267050,6 +267081,7 @@ var init_lighthouse = __esm({
267050
267081
  import_node_module = require("node:module");
267051
267082
  init_detect();
267052
267083
  init_errors();
267084
+ init_loopback();
267053
267085
  init_selection();
267054
267086
  init_artifacts();
267055
267087
  init_session_context();
@@ -267060,7 +267092,7 @@ var init_lighthouse = __esm({
267060
267092
  init_lifecycle();
267061
267093
  init_render();
267062
267094
  COMMAND_BLOCK10 = `<command name="lighthouse">
267063
- a third-party scored diagnostic report \u2014 capture runs Lighthouse against a URL and stores its report unmodified
267095
+ Google Lighthouse audits \u2014 capture runs Lighthouse against a URL and stores its scored report unmodified
267064
267096
  use for one Lighthouse report's audited accessibility, SEO, performance, or best-practices conditions, categories, scores, and audits
267065
267097
  </command>`;
267066
267098
  HELP14 = `capture lighthouse <url> [--categories <list>] [--preset mobile|desktop] [--limit <N>] [--out <path>] \u2014 run Lighthouse's third-party scored diagnostic sweep against a URL and store its report
@@ -267289,14 +267321,14 @@ function rootHelp() {
267289
267321
 
267290
267322
  ${ROOT_BLOCKS.join("\n\n")}
267291
267323
 
267292
- Selecting a noun: session, page, and tab address the live browser context; measure, motion, perf, and heap each own a distinct recorded substrate; lighthouse, cdp, and lib are respectively third-party audit, raw protocol, and local-library surfaces. Use a substrate noun when its recorded artifact is the source of the facts you need.
267324
+ Selecting a noun: session, page, and tab address the live browser; measure, motion, perf, and heap each record once and then answer many questions from that recording \u2014 a settled snapshot, a screencast, a performance trace, a heap snapshot; lighthouse, cdp, and lib are third-party audit, raw protocol, and local-library surfaces. Record with the noun whose artifact holds the facts you need, then query it.
267293
267325
 
267294
267326
  I/O contract: flags and positionals on input; one rendered prose block on stdout. --json mirrors the same result as JSON, but the rendered block is the contract. stderr carries in-flight diagnostics only. --artifact-dir <path> selects the root for a session bundle (\`session start\`) or a sessionless artifact-producing leaf's one-shot bundle; leaf help states when it writes one. --out always names one caller-owned file, never a bundle directory.
267295
267327
 
267296
267328
  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
267329
  }
267298
267330
  function printVersion() {
267299
- const declared = "1.4.13";
267331
+ const declared = "1.4.15";
267300
267332
  if (declared) {
267301
267333
  console.log(declared);
267302
267334
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.13",
3
+ "version": "1.4.15",
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/capture.ts CHANGED
@@ -61,7 +61,7 @@ function rootHelp(): string {
61
61
 
62
62
  ${ROOT_BLOCKS.join('\n\n')}
63
63
 
64
- Selecting a noun: session, page, and tab address the live browser context; measure, motion, perf, and heap each own a distinct recorded substrate; lighthouse, cdp, and lib are respectively third-party audit, raw protocol, and local-library surfaces. Use a substrate noun when its recorded artifact is the source of the facts you need.
64
+ Selecting a noun: session, page, and tab address the live browser; measure, motion, perf, and heap each record once and then answer many questions from that recording — a settled snapshot, a screencast, a performance trace, a heap snapshot; lighthouse, cdp, and lib are third-party audit, raw protocol, and local-library surfaces. Record with the noun whose artifact holds the facts you need, then query it.
65
65
 
66
66
  I/O contract: flags and positionals on input; one rendered prose block on stdout. --json mirrors the same result as JSON, but the rendered block is the contract. stderr carries in-flight diagnostics only. --artifact-dir <path> selects the root for a session bundle (\`session start\`) or a sessionless artifact-producing leaf's one-shot bundle; leaf help states when it writes one. --out always names one caller-owned file, never a bundle directory.
67
67
 
@@ -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',
@@ -7,8 +7,8 @@ import { cmdHeapRetainers } from './retainers.js';
7
7
  import { cmdHeapDiff } from './diff.js';
8
8
 
9
9
  export const COMMAND_BLOCK = `<command name="heap">
10
- the tab's JavaScript heap — \`snapshot\` records the heap as a V8 snapshot, every other leaf is a read-only graph query over it
11
- use for JavaScript-heap retention: constructor totals, retained sizes, retaining paths, duplicate strings, and before/after comparison
10
+ JavaScript memory — \`snapshot\` writes the tab's V8 heap snapshot; every other leaf queries that object graph without touching the browser
11
+ use when a claim depends on memory: what the heap holds per constructor, which objects exist, what keeps one alive, duplicated strings, or what grew between two snapshots when hunting a leak
12
12
  </command>`;
13
13
 
14
14
  const HELP = `<command name="heap" description="the V8 heap-snapshot substrate">
@@ -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';
@@ -16,7 +17,7 @@ import { stopAndReapCollectorHostAtSessionStop } from '../host/lifecycle.js';
16
17
  import { capped, emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type JsonValue, type RenderableResult } from '../../output/render.js';
17
18
 
18
19
  export const COMMAND_BLOCK = `<command name="lighthouse">
19
- a third-party scored diagnostic report — capture runs Lighthouse against a URL and stores its report unmodified
20
+ Google Lighthouse audits — capture runs Lighthouse against a URL and stores its scored report unmodified
20
21
  use for one Lighthouse report's audited accessibility, SEO, performance, or best-practices conditions, categories, scores, and audits
21
22
  </command>`;
22
23
 
@@ -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();
@@ -24,12 +24,12 @@ import { cmdMeasureMapPaint } from './map-paint.js';
24
24
 
25
25
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
26
26
  export const COMMAND_BLOCK = `<command name="measure">
27
- the settled-snapshot substrate — static facts at one moment; \`snap\` writes the substrate, every other leaf is a read-only query over it
28
- use for static rendered-page facts from a settled snapshot: layout, content, cascade provenance, targetability, and snapshot diffs
27
+ static page measurement — \`snap\` waits for the page to settle, then records its layout, styles, text, and accessibility into one artifact; every other leaf answers questions from that artifact without re-driving the browser
28
+ 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
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` });
@@ -16,8 +16,8 @@ import { cmdMotionResponse } from './response.js';
16
16
 
17
17
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
18
18
  export const COMMAND_BLOCK = `<command name="motion">
19
- the screencast-recording substrate — what changed on screen across frames; \`rec\` writes the recording, every other leaf is a read-only query over it
20
- use for per-element frame geometry, changed regions, and input-to-settle timing; use \`perf\` instead for trace-event timing
19
+ screen recording — \`rec\` screencasts the tab through an interaction; every other leaf answers what moved, where, and how long it took from that recording's frames
20
+ use when a claim depends on change over time: element motion across frames, which screen regions changed, layout shifts and long tasks, or the time from an input to a settled screen; for trace-event timing use \`perf\` instead
21
21
  </command>`;
22
22
 
23
23
  export const MOTION_USAGE = `<command name="motion" description="recording lifecycle and read-only queries over a finalized recording">
@@ -5,8 +5,8 @@ import { cmdPerfVitals } from './vitals.js';
5
5
  import { cmdPerfInsights } from './insights.js';
6
6
 
7
7
  export const COMMAND_BLOCK = `<command name="perf">
8
- the Chrome performance-trace substrate — \`trace\` records one, \`vitals\` and \`insights\` are read-only queries over it
9
- use for Core Web Vitals and DevTools insights over a page load or one interaction, timed from trace events
8
+ Chrome performance tracing — \`trace\` records a DevTools performance trace over a page load or an interaction; \`vitals\` and \`insights\` read it back without re-running anything
9
+ use when a claim depends on runtime performance: LCP, INP, and CLS with attribution, or DevTools' computed insights such as render-blocking requests, forced reflow, and layout-shift culprits
10
10
  </command>`;
11
11
 
12
12
  const HELP = `<command name="perf" description="the Chrome performance-trace substrate">
@@ -21,7 +21,7 @@ import { tabMockMain } from './mock/index.js';
21
21
 
22
22
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
23
23
  export const COMMAND_BLOCK = `<command name="tab">
24
- browser and tab plumbing — the existence of a browser capture owns, endpoint/tab discovery, tab lifecycle, and the conditions the tab's network runs under (connectivity, mocked responses)
24
+ browser and tab plumbing — launch or quit a browser capture owns, discover endpoints and tabs, open/close/reset tabs, and set the tab's network conditions (throttling, offline, mocked responses)
25
25
  use when starting, finding, opening, closing, or resetting a browser tab, or changing its network conditions
26
26
  </command>`;
27
27
 
@@ -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 {
@@ -276,7 +281,7 @@ export async function openTab(port: number, url: string): Promise<CDPTarget> {
276
281
  title: '',
277
282
  url,
278
283
  type: 'page',
279
- webSocketDebuggerUrl: `ws://localhost:${port}/devtools/page/${targetId}`,
284
+ webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`,
280
285
  };
281
286
  } finally {
282
287
  client.close();
@@ -383,7 +383,7 @@ export async function waitForPageLoad(
383
383
 
384
384
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
385
385
  export const COMMAND_BLOCK = `<command name="session">
386
- the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab, and once it has one, every command auto-targets it
386
+ the work's evidence bundle — \`start\` opens or adopts a tab, records its network traffic (HAR) and external logs, and collects every artifact into one directory; while running, every command auto-targets its tab
387
387
  use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
388
388
  </command>`;
389
389
 
@@ -1103,6 +1103,7 @@ function responseEndDateTime(
1103
1103
  }
1104
1104
 
1105
1105
  function completedTiming(e: HAREntry): FactLine {
1106
+ if ('_resourceType' in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
1106
1107
  const { clocks, terminal } = e._capture;
1107
1108
  if (terminal.kind === 'failed') {
1108
1109
  return fact`duration ${e.time} ms — response incomplete: failed`;