@crouton-kit/capture 1.4.19 → 1.4.21

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/README.md CHANGED
@@ -217,3 +217,5 @@ The CLI is designed to be read by a model: errors are structured, every error ca
217
217
  ## License
218
218
 
219
219
  MIT
220
+
221
+ See [CONTRIBUTING.md](CONTRIBUTING.md) to contribute.
package/bin/capture CHANGED
@@ -3781,6 +3781,7 @@ var init_loopback = __esm({
3781
3781
  var detect_exports = {};
3782
3782
  __export(detect_exports, {
3783
3783
  BROWSER_PATTERNS: () => BROWSER_PATTERNS,
3784
+ describeCdpPort: () => describeCdpPort,
3784
3785
  detectCdpEndpoint: () => detectCdpEndpoint,
3785
3786
  detectCdpPort: () => detectCdpPort,
3786
3787
  detectCdpPortsAsync: () => detectCdpPortsAsync,
@@ -3911,24 +3912,29 @@ function identifyBrowserBundleId(browser, processName) {
3911
3912
  }
3912
3913
  return "unknown";
3913
3914
  }
3915
+ async function describeEndpoint(port, processNames2) {
3916
+ const probe = await probeCdpPort(port);
3917
+ if (!probe) return null;
3918
+ const processName = processNames2.get(port);
3919
+ return {
3920
+ port,
3921
+ app: processName ?? probe.app,
3922
+ bundleId: identifyBrowserBundleId(probe.browser, processName),
3923
+ isElectron: probe.isElectron,
3924
+ hasPageTarget: probe.hasPageTarget
3925
+ };
3926
+ }
3927
+ async function describeCdpPort(port) {
3928
+ return describeEndpoint(port, getListeningProcessNames());
3929
+ }
3914
3930
  async function detectCdpPortsAsync() {
3915
3931
  const ports = getLocalhostListeningPorts();
3916
3932
  const processNames2 = getListeningProcessNames();
3917
3933
  const results = [];
3918
- const probes = ports.map(async (port) => {
3919
- const probe = await probeCdpPort(port);
3920
- if (probe) {
3921
- const processName = processNames2.get(port);
3922
- results.push({
3923
- port,
3924
- app: processName ?? probe.app,
3925
- bundleId: identifyBrowserBundleId(probe.browser, processName),
3926
- isElectron: probe.isElectron,
3927
- hasPageTarget: probe.hasPageTarget
3928
- });
3929
- }
3930
- });
3931
- await Promise.all(probes);
3934
+ await Promise.all(ports.map(async (port) => {
3935
+ const endpoint = await describeEndpoint(port, processNames2);
3936
+ if (endpoint) results.push(endpoint);
3937
+ }));
3932
3938
  return results;
3933
3939
  }
3934
3940
  function pickPreferredEndpointWithReason(endpoints, defaultBrowser) {
@@ -8730,6 +8736,7 @@ async function viewportScopedCapture(client, viewport2, options, includeCssViewp
8730
8736
  } catch (error4) {
8731
8737
  if (options?.crop) throw error4;
8732
8738
  }
8739
+ await options?.beforeCapture?.();
8733
8740
  const result = await client.send(
8734
8741
  "Page.captureScreenshot",
8735
8742
  screenshotOpts,
@@ -10559,7 +10566,7 @@ function validateHarAppendBatch(value, source) {
10559
10566
  return { entries, incompleteLifecycles };
10560
10567
  }
10561
10568
  function captureVersion() {
10562
- const version = "1.4.19";
10569
+ const version = "1.4.21";
10563
10570
  return typeof version === "string" && version.length > 0 ? version : "development";
10564
10571
  }
10565
10572
  function token() {
@@ -12661,7 +12668,7 @@ function wallTime(value) {
12661
12668
  }
12662
12669
  }
12663
12670
  function captureVersion2() {
12664
- const version = "1.4.19";
12671
+ const version = "1.4.21";
12665
12672
  return typeof version === "string" && version.length > 0 ? version : "development";
12666
12673
  }
12667
12674
  function deepFreeze(value) {
@@ -15128,6 +15135,15 @@ function rejectSurplusPositionals(parsed, leaf, max, usage) {
15128
15135
  process.exitCode = 1;
15129
15136
  return true;
15130
15137
  }
15138
+ async function tabStillListed(port, targetId) {
15139
+ await new Promise((resolve12) => setTimeout(resolve12, 250));
15140
+ try {
15141
+ const found = await startWorld.findTabById(port, targetId);
15142
+ return found !== null && found.type === "page";
15143
+ } catch {
15144
+ return false;
15145
+ }
15146
+ }
15131
15147
  async function start(parsed) {
15132
15148
  if (parsed.help) {
15133
15149
  console.log(START_USAGE);
@@ -15206,6 +15222,21 @@ async function start(parsed) {
15206
15222
  const openedTargetId = target.id;
15207
15223
  acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15208
15224
  pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15225
+ if (!await tabStillListed(cdpPort, target.id)) {
15226
+ const lostId = target.id;
15227
+ await startWorld.closeTarget(cdpPort, lostId).catch(() => void 0);
15228
+ acquired.pop();
15229
+ target = await startWorld.openTab(cdpPort, url);
15230
+ const retryPort = cdpPort;
15231
+ const retryId = target.id;
15232
+ acquired.push({ label: "opened target", release: () => startWorld.closeTarget(retryPort, retryId) });
15233
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15234
+ if (!await tabStillListed(cdpPort, target.id)) {
15235
+ throw worldFailure(
15236
+ `Port ${cdpPort} drops tabs created over CDP (${retryId} vanished twice). Use \`capture tab open ${url} --port ${cdpPort}\`, then \`capture session start --port ${cdpPort} --target <id>\`.`
15237
+ );
15238
+ }
15239
+ }
15209
15240
  }
15210
15241
  }
15211
15242
  if (hold) {
@@ -16422,6 +16453,14 @@ async function connectForCommand(parsed) {
16422
16453
  const tab = resolved?.tab ?? null;
16423
16454
  if (!tab) {
16424
16455
  const query = parsed.target ?? parsed.url;
16456
+ if (parsed.targetSource === "session" && activeSession2) {
16457
+ const where = activeSession2.port ? ` on port ${activeSession2.port}` : "";
16458
+ throw captureError(
16459
+ "precondition",
16460
+ "target_unavailable",
16461
+ `Session ${activeSession2.sessionId}'s tab ${query}${where} is no longer listed \u2014 the browser closed or dropped it. Open a replacement with \`capture tab reset <url>${activeSession2.port ? ` --port ${activeSession2.port}` : ""}\` (repoints this session at it), or pick a live tab with \`capture tab list\` and pass \`--target <id>\`.`
16462
+ );
16463
+ }
16425
16464
  throw captureError(
16426
16465
  "precondition",
16427
16466
  "target_unavailable",
@@ -16984,9 +17023,20 @@ async function axIdentityFor(client, backendNodeId2) {
16984
17023
  return { role: null, name: null };
16985
17024
  }
16986
17025
  }
17026
+ async function clickLiveTarget(client, input, opts = {}) {
17027
+ const initial = await resolveLiveTarget(client, input);
17028
+ if (!initial.ok) return initial;
17029
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: initial.backendNodeId });
17030
+ const current = await resolveLiveTarget(client, input);
17031
+ if (!current.ok) return current;
17032
+ return dispatchResolvedClick(client, current, opts);
17033
+ }
16987
17034
  async function clickResolved(client, resolved, opts = {}) {
17035
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: resolved.backendNodeId });
17036
+ return dispatchResolvedClick(client, resolved, opts);
17037
+ }
17038
+ async function dispatchResolvedClick(client, resolved, opts) {
16988
17039
  const { backendNodeId: backendNodeId2 } = resolved;
16989
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: backendNodeId2 });
16990
17040
  const boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
16991
17041
  const quad = boxResponse?.model?.content;
16992
17042
  if (!isContentQuad(quad)) {
@@ -17003,7 +17053,18 @@ async function clickResolved(client, resolved, opts = {}) {
17003
17053
  const dispatchY = Math.round(y);
17004
17054
  let hitTestReceiverBackendNodeId;
17005
17055
  if (opts.inspectHitTest) {
17006
- const hitTest = await client.send("DOM.getNodeForLocation", { x: dispatchX, y: dispatchY });
17056
+ const metrics = await client.send("Page.getLayoutMetrics");
17057
+ const viewport2 = metrics.cssVisualViewport;
17058
+ if (!viewport2 || !Number.isFinite(viewport2.pageX) || !Number.isFinite(viewport2.pageY)) {
17059
+ throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
17060
+ method: "Page.getLayoutMetrics",
17061
+ response: metrics
17062
+ });
17063
+ }
17064
+ const hitTest = await client.send("DOM.getNodeForLocation", {
17065
+ x: Math.round(x + viewport2.pageX),
17066
+ y: Math.round(y + viewport2.pageY)
17067
+ });
17007
17068
  if (typeof hitTest.backendNodeId !== "number" || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
17008
17069
  throw captureError(
17009
17070
  "world",
@@ -17219,9 +17280,9 @@ async function cmdPageClick(parsed, _args) {
17219
17280
  { settleMs: settle },
17220
17281
  async (client) => {
17221
17282
  const live = client;
17222
- const resolved = await resolveLiveTarget(live, target);
17223
- if (!resolved.ok) return { failure: resolved };
17224
- const dispatch2 = await clickResolved(live, resolved, { inspectHitTest: true });
17283
+ const clicked = await clickLiveTarget(live, target, { inspectHitTest: true });
17284
+ if ("ok" in clicked) return { failure: clicked };
17285
+ const dispatch2 = clicked;
17225
17286
  const screenshotResult = await capturePageInputScreenshot(client, "click", target, parsed.noScreenshot, parsed.out);
17226
17287
  return { dispatch: dispatch2, ...screenshotResult };
17227
17288
  }
@@ -229039,6 +229100,29 @@ async function pageBorderRect(client, backendNodeId2) {
229039
229100
  }
229040
229101
  return { ...rect3, x: rect3.x + pageX, y: rect3.y + pageY };
229041
229102
  }
229103
+ async function finishColorTransitions(client) {
229104
+ const tree = await client.send("Page.getFrameTree");
229105
+ if (!tree.frameTree) throw new Error("Page.getFrameTree returned no frames for color-scheme capture.");
229106
+ const frames2 = [tree.frameTree];
229107
+ for (const frame of frames2) {
229108
+ frames2.push(...frame.childFrames ?? []);
229109
+ const world2 = await client.send("Page.createIsolatedWorld", {
229110
+ frameId: frame.frame.id,
229111
+ worldName: "capture-color-scheme"
229112
+ });
229113
+ if (world2.executionContextId === void 0) throw new Error(`No execution context for frame ${frame.frame.id}.`);
229114
+ const result = await client.send("Runtime.evaluate", {
229115
+ contextId: world2.executionContextId,
229116
+ expression: `(() => {
229117
+ document.getAnimations({ subtree: true })
229118
+ .filter(animation => animation instanceof CSSTransition)
229119
+ .forEach(transition => transition.finish());
229120
+ })()`,
229121
+ returnByValue: true
229122
+ });
229123
+ if (!result.result || result.exceptionDetails) throw new Error(`Could not finish CSS transitions in frame ${frame.frame.id}: ${result.exceptionDetails?.text ?? "page evaluation failed"}`);
229124
+ }
229125
+ }
229042
229126
  async function cmdPageShot(parsed, _args) {
229043
229127
  if (parsed.help) {
229044
229128
  console.log(USAGE6);
@@ -229142,7 +229226,12 @@ async function cmdPageShot(parsed, _args) {
229142
229226
  } else if (requestedCrop) {
229143
229227
  cropProvenance = { source: "coordinates", requested: requestedCrop, zoom };
229144
229228
  }
229145
- const screenshot = await captureScreenshotWithCssViewport(client, viewport2, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
229229
+ const screenshot = await captureScreenshotWithCssViewport(client, viewport2, {
229230
+ fullPage: parsed.fullPage,
229231
+ crop: requestedCrop,
229232
+ zoom,
229233
+ ...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
229234
+ });
229146
229235
  const outPath = await resolveOutPath(parsed);
229147
229236
  writeScreenshot(outPath, screenshot.png);
229148
229237
  return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, scrollOffset: screenshot.scrollOffset, crop: cropProvenance };
@@ -229173,7 +229262,7 @@ var init_shot = __esm({
229173
229262
  input:
229174
229263
  --viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace \u2014 preset names are not accepted. Absent \u2192 no emulation, the browser's actual current viewport is captured
229175
229264
  --full-page transient override to the full scrollable content height for this capture
229176
- --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
229265
+ --color-scheme <dark|light> transient prefers-color-scheme override for this capture; CSS transitions finish before the shot; cleared afterward
229177
229266
  --crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
229178
229267
  --crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
229179
229268
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
@@ -229185,7 +229274,7 @@ input:
229185
229274
  output:
229186
229275
  <screenshot path=\u2026 width=\u2026 height=\u2026 css-width=\u2026 css-height=\u2026 css-x=\u2026 css-y=\u2026 css-to-image-x=\u2026 css-to-image-y=\u2026 effective-downscale-x=\u2026 effective-downscale-y=\u2026 scroll-x=\u2026 scroll-y=\u2026 emulation=none|viewport|full-page color-scheme=dark|light> \u2014 saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
229187
229276
  effects:
229188
- no flags: none \u2014 the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture \u2014 two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
229277
+ no flags: none \u2014 the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture \u2014 two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override, finishes CSS transitions before capture, and clears the override afterward. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
229189
229278
  deps4 = { withConnection, nextStepPath, createOneshotSession };
229190
229279
  PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
229191
229280
  LEGIBILITY_SCALE_THRESHOLD = 0.5;
@@ -229909,6 +229998,9 @@ effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, ver
229909
229998
  });
229910
229999
 
229911
230000
  // src/cdp/commands/tab/list.ts
230001
+ function endpointNote(app) {
230002
+ return app !== void 0 && /^arc$/i.test(app.trim()) ? ARC_ENDPOINT_NOTE : void 0;
230003
+ }
229912
230004
  function buildTabsResult(endpoints, unreachable) {
229913
230005
  const totalTabs = endpoints.reduce((n, ep) => n + ep.pages.length, 0);
229914
230006
  let summary = endpoints.length === 0 && unreachable.length === 0 ? text`0 CDP endpoints found listening on localhost.` : fact`${totalTabs} page tab(s) on ${endpoints.length} reachable CDP endpoint(s).`;
@@ -229927,7 +230019,8 @@ function buildTabsResult(endpoints, unreachable) {
229927
230019
  const rows = ep.pages.map(
229928
230020
  (t) => line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300))
229929
230021
  );
229930
- return lineList([header, ...rows]);
230022
+ const note = endpointNote(ep.app);
230023
+ return lineList([header, ...rows, ...note ? [note] : []]);
229931
230024
  });
229932
230025
  if (unreachable.length > 0) {
229933
230026
  sections.push(
@@ -229970,8 +230063,10 @@ async function cmdTabList(parsed, _args) {
229970
230063
  if (parsed.port) {
229971
230064
  try {
229972
230065
  const targets = await listTargets(parsed.port);
230066
+ const described = await describeCdpPort(parsed.port).catch(() => null);
229973
230067
  reachable.push({
229974
230068
  port: parsed.port,
230069
+ ...described ? { app: described.app } : {},
229975
230070
  preferred: false,
229976
230071
  ...owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port).pid } : {},
229977
230072
  pages: targets.filter((t) => t.type === "page").map((t) => ({ id: t.id, title: t.title, url: t.url }))
@@ -230003,7 +230098,7 @@ async function cmdTabList(parsed, _args) {
230003
230098
  }
230004
230099
  emitResult(buildTabsResult(reachable, unreachable), { json: parsed.json });
230005
230100
  }
230006
- var USAGE11;
230101
+ var USAGE11, ARC_ENDPOINT_NOTE;
230007
230102
  var init_list = __esm({
230008
230103
  "src/cdp/commands/tab/list.ts"() {
230009
230104
  init_detect();
@@ -230017,6 +230112,7 @@ input:
230017
230112
 
230018
230113
  output: <tabs endpoints=\u2026 tabs=\u2026> \u2014 one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
230019
230114
  effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle \u2014 browsers capture did not start are never signalled.`;
230115
+ ARC_ENDPOINT_NOTE = text` note: Arc exposes only tabs opened over CDP as page targets; the tabs in its windows are not listed. Open one with "capture tab open <url> --port <port>", then "capture session start --port <port> --target <id>".`;
230020
230116
  }
230021
230117
  });
230022
230118
 
@@ -267362,7 +267458,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267362
267458
  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.`;
267363
267459
  }
267364
267460
  function printVersion() {
267365
- const declared = "1.4.19";
267461
+ const declared = "1.4.21";
267366
267462
  if (declared) {
267367
267463
  console.log(declared);
267368
267464
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.19",
3
+ "version": "1.4.21",
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"
@@ -14,8 +14,7 @@ import { withPageAction } from '../../connection.js';
14
14
  import { autoScreenshot } from '../../screenshot.js';
15
15
  import { getActiveSession } from '../../../session-context.js';
16
16
  import {
17
- resolveLiveTarget,
18
- clickResolved,
17
+ clickLiveTarget,
19
18
  type LiveClient,
20
19
  type ResolutionFailure,
21
20
  } from '../../../interact.js';
@@ -206,9 +205,9 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
206
205
  { settleMs: settle },
207
206
  async (client) => {
208
207
  const live = client as unknown as LiveClient;
209
- const resolved = await resolveLiveTarget(live, target);
210
- if (!resolved.ok) return { failure: resolved } as const;
211
- const dispatch = await clickResolved(live, resolved, { inspectHitTest: true });
208
+ const clicked = await clickLiveTarget(live, target, { inspectHitTest: true });
209
+ if ('ok' in clicked) return { failure: clicked } as const;
210
+ const dispatch = clicked;
212
211
  const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot, parsed.out);
213
212
  return { dispatch, ...screenshotResult } as const;
214
213
  },
@@ -39,7 +39,7 @@ const USAGE = `capture page shot — capture a PNG of the tab as it is right now
39
39
  input:
40
40
  --viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
41
41
  --full-page transient override to the full scrollable content height for this capture
42
- --color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
42
+ --color-scheme <dark|light> transient prefers-color-scheme override for this capture; CSS transitions finish before the shot; cleared afterward
43
43
  --crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
44
44
  --crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
45
45
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
@@ -51,7 +51,7 @@ input:
51
51
  output:
52
52
  <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… effective-downscale-x=… effective-downscale-y=… scroll-x=… scroll-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
53
53
  effects:
54
- no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
54
+ no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override, finishes CSS transitions before capture, and clears the override afterward. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
55
55
 
56
56
  // ---------------------------------------------------------------------------
57
57
  // Test-injectable dependency seam (the CDP-stub test pattern; the capture
@@ -273,6 +273,31 @@ async function pageBorderRect(client: CDPClient, backendNodeId: number): Promise
273
273
  // page shot
274
274
  // ---------------------------------------------------------------------------
275
275
 
276
+ async function finishColorTransitions(client: CDPClient): Promise<void> {
277
+ type FrameTree = { frame: { id: string }; childFrames?: FrameTree[] };
278
+ const tree = (await client.send('Page.getFrameTree')) as { frameTree?: FrameTree };
279
+ if (!tree.frameTree) throw new Error('Page.getFrameTree returned no frames for color-scheme capture.');
280
+ const frames = [tree.frameTree];
281
+ for (const frame of frames) {
282
+ frames.push(...(frame.childFrames ?? []));
283
+ const world = (await client.send('Page.createIsolatedWorld', {
284
+ frameId: frame.frame.id,
285
+ worldName: 'capture-color-scheme',
286
+ })) as { executionContextId?: number };
287
+ if (world.executionContextId === undefined) throw new Error(`No execution context for frame ${frame.frame.id}.`);
288
+ const result = (await client.send('Runtime.evaluate', {
289
+ contextId: world.executionContextId,
290
+ expression: `(() => {
291
+ document.getAnimations({ subtree: true })
292
+ .filter(animation => animation instanceof CSSTransition)
293
+ .forEach(transition => transition.finish());
294
+ })()`,
295
+ returnByValue: true,
296
+ })) as { result?: object; exceptionDetails?: { text?: string } };
297
+ if (!result.result || result.exceptionDetails) throw new Error(`Could not finish CSS transitions in frame ${frame.frame.id}: ${result.exceptionDetails?.text ?? 'page evaluation failed'}`);
298
+ }
299
+ }
300
+
276
301
  export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<void> {
277
302
  if (parsed.help) {
278
303
  console.log(USAGE);
@@ -384,7 +409,10 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
384
409
  } else if (requestedCrop) {
385
410
  cropProvenance = { source: 'coordinates', requested: requestedCrop, zoom };
386
411
  }
387
- const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
412
+ const screenshot = await captureScreenshotWithCssViewport(client, viewport, {
413
+ fullPage: parsed.fullPage, crop: requestedCrop, zoom,
414
+ ...(colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}),
415
+ });
388
416
  const outPath = await resolveOutPath(parsed);
389
417
  writeScreenshot(outPath, screenshot.png);
390
418
  return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, scrollOffset: screenshot.scrollOffset, crop: cropProvenance } as const;
@@ -8,6 +8,7 @@
8
8
  * (titles, URLs, app names, target ids) flows through `data()`/`fact` (I-9).
9
9
  */
10
10
  import {
11
+ describeCdpPort,
11
12
  detectCdpPortsAsync,
12
13
  getDefaultBrowserId,
13
14
  pickPreferredEndpoint,
@@ -58,6 +59,13 @@ export interface UnreachableEndpoint {
58
59
  reason: string;
59
60
  }
60
61
 
62
+ /** Arc lists only the tabs created over CDP: its ordinary tabs are not page targets. */
63
+ const ARC_ENDPOINT_NOTE: FactLine = text` note: Arc exposes only tabs opened over CDP as page targets; the tabs in its windows are not listed. Open one with "capture tab open <url> --port <port>", then "capture session start --port <port> --target <id>".`;
64
+
65
+ function endpointNote(app: string | undefined): FactLine | undefined {
66
+ return app !== undefined && /^arc$/i.test(app.trim()) ? ARC_ENDPOINT_NOTE : undefined;
67
+ }
68
+
61
69
  /** Pure `<tabs>` result builder — exported for tests. */
62
70
  export function buildTabsResult(
63
71
  endpoints: readonly EndpointTabs[],
@@ -85,7 +93,8 @@ export function buildTabsResult(
85
93
  const rows = ep.pages.map((t) =>
86
94
  line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300)),
87
95
  );
88
- return lineList([header, ...rows]);
96
+ const note = endpointNote(ep.app);
97
+ return lineList([header, ...rows, ...(note ? [note] : [])]);
89
98
  });
90
99
 
91
100
  if (unreachable.length > 0) {
@@ -137,8 +146,10 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
137
146
  if (parsed.port) {
138
147
  try {
139
148
  const targets = await listTargets(parsed.port);
149
+ const described = await describeCdpPort(parsed.port).catch(() => null);
140
150
  reachable.push({
141
151
  port: parsed.port,
152
+ ...(described ? { app: described.app } : {}),
142
153
  preferred: false,
143
154
  ...(owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port)!.pid } : {}),
144
155
  pages: targets
@@ -204,6 +204,14 @@ export async function connectForCommand(
204
204
 
205
205
  if (!tab) {
206
206
  const query = parsed.target ?? parsed.url;
207
+ if (parsed.targetSource === 'session' && activeSession) {
208
+ const where = activeSession.port ? ` on port ${activeSession.port}` : '';
209
+ throw captureError(
210
+ 'precondition',
211
+ 'target_unavailable',
212
+ `Session ${activeSession.sessionId}'s tab ${query}${where} is no longer listed — the browser closed or dropped it. Open a replacement with \`capture tab reset <url>${activeSession.port ? ` --port ${activeSession.port}` : ''}\` (repoints this session at it), or pick a live tab with \`capture tab list\` and pass \`--target <id>\`.`,
213
+ );
214
+ }
207
215
  throw captureError(
208
216
  'precondition',
209
217
  'target_unavailable',
package/src/cdp/detect.ts CHANGED
@@ -199,27 +199,34 @@ export interface CdpEndpoint {
199
199
  hasPageTarget: boolean;
200
200
  }
201
201
 
202
+ async function describeEndpoint(port: number, processNames: Map<number, string>): Promise<CdpEndpoint | null> {
203
+ const probe = await probeCdpPort(port);
204
+ if (!probe) return null;
205
+ const processName = processNames.get(port);
206
+ return {
207
+ port,
208
+ app: processName ?? probe.app,
209
+ bundleId: identifyBrowserBundleId(probe.browser, processName),
210
+ isElectron: probe.isElectron,
211
+ hasPageTarget: probe.hasPageTarget,
212
+ };
213
+ }
214
+
215
+ /** Identifies the app serving one known CDP port (null when it is not a CDP browser). */
216
+ export async function describeCdpPort(port: number): Promise<CdpEndpoint | null> {
217
+ return describeEndpoint(port, getListeningProcessNames());
218
+ }
219
+
202
220
  export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
203
221
  const ports = getLocalhostListeningPorts();
204
222
  const processNames = getListeningProcessNames();
205
223
  const results: CdpEndpoint[] = [];
206
224
 
207
225
  // Probe ports in parallel for speed
208
- const probes = ports.map(async (port) => {
209
- const probe = await probeCdpPort(port);
210
- if (probe) {
211
- const processName = processNames.get(port);
212
- results.push({
213
- port,
214
- app: processName ?? probe.app,
215
- bundleId: identifyBrowserBundleId(probe.browser, processName),
216
- isElectron: probe.isElectron,
217
- hasPageTarget: probe.hasPageTarget,
218
- });
219
- }
220
- });
221
-
222
- await Promise.all(probes);
226
+ await Promise.all(ports.map(async (port) => {
227
+ const endpoint = await describeEndpoint(port, processNames);
228
+ if (endpoint) results.push(endpoint);
229
+ }));
223
230
  return results;
224
231
  }
225
232
 
@@ -31,7 +31,7 @@ export interface ScreenshotCrop {
31
31
  readonly height: number;
32
32
  }
33
33
 
34
- type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number };
34
+ type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number; beforeCapture?: () => Promise<void> };
35
35
 
36
36
  /** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
37
37
  function validRegion(region: CapturedRegion): CapturedRegion | undefined {
@@ -246,6 +246,7 @@ async function viewportScopedCapture(
246
246
  // Capture without downscaling when the optional metrics probe is unavailable.
247
247
  }
248
248
 
249
+ await options?.beforeCapture?.();
249
250
  const result = (await client.send(
250
251
  'Page.captureScreenshot',
251
252
  screenshotOpts,
package/src/interact.ts CHANGED
@@ -352,23 +352,44 @@ async function axIdentityFor(
352
352
  // ---------------------------------------------------------------------------
353
353
 
354
354
  /**
355
- * Clicks a resolved target: scroll into view → box model → center-point
356
- * `Input.dispatchMouseEvent` press/release pair. When the caller supplies
355
+ * Resolves a live target, scrolls it into view, then resolves it again before
356
+ * dispatching a center-point `Input.dispatchMouseEvent` press/release pair. When the caller supplies
357
357
  * `opts.mark` and the transport records landmarks
358
358
  * ({@link LiveClient.sendMarked}), the INITIATING PRESS alone carries the
359
359
  * label — one logical click is one landmark, never two (the release and every
360
360
  * incidental call stay unmarked). Without a marked lane the press degrades to
361
361
  * a plain `send` (nothing is recording landmarks).
362
362
  */
363
+ export async function clickLiveTarget(
364
+ client: LiveClient,
365
+ input: string,
366
+ opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
367
+ ): Promise<ClickDispatch | ResolutionFailure> {
368
+ const initial = await resolveLiveTarget(client, input);
369
+ if (!initial.ok) return initial;
370
+
371
+ await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: initial.backendNodeId });
372
+
373
+ const current = await resolveLiveTarget(client, input);
374
+ if (!current.ok) return current;
375
+ return dispatchResolvedClick(client, current, opts);
376
+ }
377
+
363
378
  export async function clickResolved(
364
379
  client: LiveClient,
365
380
  resolved: ResolvedTarget,
366
381
  opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
367
382
  ): Promise<ClickDispatch> {
368
- const { backendNodeId } = resolved;
369
-
370
- await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId });
383
+ await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: resolved.backendNodeId });
384
+ return dispatchResolvedClick(client, resolved, opts);
385
+ }
371
386
 
387
+ async function dispatchResolvedClick(
388
+ client: LiveClient,
389
+ resolved: ResolvedTarget,
390
+ opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean },
391
+ ): Promise<ClickDispatch> {
392
+ const { backendNodeId } = resolved;
372
393
  const boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as
373
394
  | { model?: { content?: unknown } }
374
395
  | undefined;
@@ -391,7 +412,17 @@ export async function clickResolved(
391
412
  const dispatchY = Math.round(y);
392
413
  let hitTestReceiverBackendNodeId: number | undefined;
393
414
  if (opts.inspectHitTest) {
394
- const hitTest = (await client.send('DOM.getNodeForLocation', { x: dispatchX, y: dispatchY })) as { backendNodeId?: unknown };
415
+ // The box quad and mouse dispatch use viewport coordinates; DOM.getNodeForLocation uses page coordinates.
416
+ const metrics = (await client.send('Page.getLayoutMetrics')) as { cssVisualViewport?: { pageX: number; pageY: number } };
417
+ const viewport = metrics.cssVisualViewport;
418
+ if (!viewport || !Number.isFinite(viewport.pageX) || !Number.isFinite(viewport.pageY)) {
419
+ throw captureError('world', 'malformed_protocol', 'Page.getLayoutMetrics returned no valid CSS visual viewport.', {
420
+ method: 'Page.getLayoutMetrics', response: metrics,
421
+ });
422
+ }
423
+ const hitTest = (await client.send('DOM.getNodeForLocation', {
424
+ x: Math.round(x + viewport.pageX), y: Math.round(y + viewport.pageY),
425
+ })) as { backendNodeId?: unknown };
395
426
  if (typeof hitTest.backendNodeId !== 'number' || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
396
427
  throw captureError(
397
428
  'world',
@@ -660,6 +660,17 @@ interface Acquisition {
660
660
  release(): Promise<void> | void;
661
661
  }
662
662
 
663
+ /** True when the port's target list still contains this page target. */
664
+ async function tabStillListed(port: number, targetId: string): Promise<boolean> {
665
+ await new Promise((resolve) => setTimeout(resolve, 250));
666
+ try {
667
+ const found = await startWorld.findTabById(port, targetId);
668
+ return found !== null && found.type === 'page';
669
+ } catch {
670
+ return false;
671
+ }
672
+ }
673
+
663
674
  async function start(parsed: ParsedArgs): Promise<void> {
664
675
  if (parsed.help) {
665
676
  console.log(START_USAGE);
@@ -750,6 +761,24 @@ async function start(parsed: ParsedArgs): Promise<void> {
750
761
  const openedTargetId = target.id;
751
762
  acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
752
763
  pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
764
+ // Some browsers (Arc) acknowledge Target.createTarget and then drop the
765
+ // page. Reporting that tab as the session's target would leave every
766
+ // later command pointing at nothing, so confirm it is still listed.
767
+ if (!await tabStillListed(cdpPort!, target.id)) {
768
+ const lostId = target.id;
769
+ await startWorld.closeTarget(cdpPort!, lostId).catch(() => undefined);
770
+ acquired.pop();
771
+ target = await startWorld.openTab(cdpPort!, url);
772
+ const retryPort = cdpPort!;
773
+ const retryId = target.id;
774
+ acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(retryPort, retryId) });
775
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
776
+ if (!await tabStillListed(cdpPort!, target.id)) {
777
+ throw worldFailure(
778
+ `Port ${cdpPort} drops tabs created over CDP (${retryId} vanished twice). Use \`capture tab open ${url} --port ${cdpPort}\`, then \`capture session start --port ${cdpPort} --target <id>\`.`,
779
+ );
780
+ }
781
+ }
753
782
  }
754
783
  }
755
784