@crouton-kit/capture 1.4.20 → 1.4.22

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
@@ -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,
@@ -9406,7 +9413,12 @@ function validateCliInvocation(parsed) {
9406
9413
  }
9407
9414
  if (leaf === "launch" || leaf === "list") requireCount(values, 0, 0, `tab ${leaf}`);
9408
9415
  else if (leaf === "quit") requireCount(values, 0, 1, "tab quit");
9409
- else requireCount(values, 1, 1, `tab ${leaf}`);
9416
+ else if (leaf === "close" && parsed.target !== void 0) {
9417
+ requireCount(values, 0, 1, "tab close");
9418
+ if (values.length === 1 && values[0] !== parsed.target) {
9419
+ throw schemaInput("tab close", `<target>=${values[0]}, --target=${parsed.target}`, "one tab: the positional <target> or --target, not two different ids", "<target> or --target");
9420
+ }
9421
+ } else requireCount(values, 1, 1, `tab ${leaf}`);
9410
9422
  if (leaf === "network" && values[0] !== "offline" && values[0] !== "online") {
9411
9423
  throw schemaInput("tab network", values[0] ?? "(missing)", "offline or online", "<mode>");
9412
9424
  }
@@ -10559,7 +10571,7 @@ function validateHarAppendBatch(value, source) {
10559
10571
  return { entries, incompleteLifecycles };
10560
10572
  }
10561
10573
  function captureVersion() {
10562
- const version = "1.4.20";
10574
+ const version = "1.4.22";
10563
10575
  return typeof version === "string" && version.length > 0 ? version : "development";
10564
10576
  }
10565
10577
  function token() {
@@ -12661,7 +12673,7 @@ function wallTime(value) {
12661
12673
  }
12662
12674
  }
12663
12675
  function captureVersion2() {
12664
- const version = "1.4.20";
12676
+ const version = "1.4.22";
12665
12677
  return typeof version === "string" && version.length > 0 ? version : "development";
12666
12678
  }
12667
12679
  function deepFreeze(value) {
@@ -15128,6 +15140,15 @@ function rejectSurplusPositionals(parsed, leaf, max, usage) {
15128
15140
  process.exitCode = 1;
15129
15141
  return true;
15130
15142
  }
15143
+ async function tabStillListed(port, targetId) {
15144
+ await new Promise((resolve12) => setTimeout(resolve12, 250));
15145
+ try {
15146
+ const found = await startWorld.findTabById(port, targetId);
15147
+ return found !== null && found.type === "page";
15148
+ } catch {
15149
+ return false;
15150
+ }
15151
+ }
15131
15152
  async function start(parsed) {
15132
15153
  if (parsed.help) {
15133
15154
  console.log(START_USAGE);
@@ -15206,6 +15227,21 @@ async function start(parsed) {
15206
15227
  const openedTargetId = target.id;
15207
15228
  acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
15208
15229
  pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15230
+ if (!await tabStillListed(cdpPort, target.id)) {
15231
+ const lostId = target.id;
15232
+ await startWorld.closeTarget(cdpPort, lostId).catch(() => void 0);
15233
+ acquired.pop();
15234
+ target = await startWorld.openTab(cdpPort, url);
15235
+ const retryPort = cdpPort;
15236
+ const retryId = target.id;
15237
+ acquired.push({ label: "opened target", release: () => startWorld.closeTarget(retryPort, retryId) });
15238
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
15239
+ if (!await tabStillListed(cdpPort, target.id)) {
15240
+ throw worldFailure(
15241
+ `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>\`.`
15242
+ );
15243
+ }
15244
+ }
15209
15245
  }
15210
15246
  }
15211
15247
  if (hold) {
@@ -16422,6 +16458,14 @@ async function connectForCommand(parsed) {
16422
16458
  const tab = resolved?.tab ?? null;
16423
16459
  if (!tab) {
16424
16460
  const query = parsed.target ?? parsed.url;
16461
+ if (parsed.targetSource === "session" && activeSession2) {
16462
+ const where = activeSession2.port ? ` on port ${activeSession2.port}` : "";
16463
+ throw captureError(
16464
+ "precondition",
16465
+ "target_unavailable",
16466
+ `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>\`.`
16467
+ );
16468
+ }
16425
16469
  throw captureError(
16426
16470
  "precondition",
16427
16471
  "target_unavailable",
@@ -16984,9 +17028,20 @@ async function axIdentityFor(client, backendNodeId2) {
16984
17028
  return { role: null, name: null };
16985
17029
  }
16986
17030
  }
17031
+ async function clickLiveTarget(client, input, opts = {}) {
17032
+ const initial = await resolveLiveTarget(client, input);
17033
+ if (!initial.ok) return initial;
17034
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: initial.backendNodeId });
17035
+ const current = await resolveLiveTarget(client, input);
17036
+ if (!current.ok) return current;
17037
+ return dispatchResolvedClick(client, current, opts);
17038
+ }
16987
17039
  async function clickResolved(client, resolved, opts = {}) {
17040
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: resolved.backendNodeId });
17041
+ return dispatchResolvedClick(client, resolved, opts);
17042
+ }
17043
+ async function dispatchResolvedClick(client, resolved, opts) {
16988
17044
  const { backendNodeId: backendNodeId2 } = resolved;
16989
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: backendNodeId2 });
16990
17045
  const boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
16991
17046
  const quad = boxResponse?.model?.content;
16992
17047
  if (!isContentQuad(quad)) {
@@ -17003,7 +17058,18 @@ async function clickResolved(client, resolved, opts = {}) {
17003
17058
  const dispatchY = Math.round(y);
17004
17059
  let hitTestReceiverBackendNodeId;
17005
17060
  if (opts.inspectHitTest) {
17006
- const hitTest = await client.send("DOM.getNodeForLocation", { x: dispatchX, y: dispatchY });
17061
+ const metrics = await client.send("Page.getLayoutMetrics");
17062
+ const viewport2 = metrics.cssVisualViewport;
17063
+ if (!viewport2 || !Number.isFinite(viewport2.pageX) || !Number.isFinite(viewport2.pageY)) {
17064
+ throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
17065
+ method: "Page.getLayoutMetrics",
17066
+ response: metrics
17067
+ });
17068
+ }
17069
+ const hitTest = await client.send("DOM.getNodeForLocation", {
17070
+ x: Math.round(x + viewport2.pageX),
17071
+ y: Math.round(y + viewport2.pageY)
17072
+ });
17007
17073
  if (typeof hitTest.backendNodeId !== "number" || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
17008
17074
  throw captureError(
17009
17075
  "world",
@@ -17014,6 +17080,7 @@ async function clickResolved(client, resolved, opts = {}) {
17014
17080
  }
17015
17081
  if (hitTest.backendNodeId !== backendNodeId2 || opts.includeHitTestReceiver) hitTestReceiverBackendNodeId = hitTest.backendNodeId;
17016
17082
  }
17083
+ await presentFrameBeforeInput(client);
17017
17084
  const pressParams = { type: "mousePressed", x: dispatchX, y: dispatchY, button: "left", clickCount: 1 };
17018
17085
  if (opts.mark !== void 0 && client.sendMarked) {
17019
17086
  await client.sendMarked("Input.dispatchMouseEvent", pressParams, opts.mark);
@@ -17030,6 +17097,12 @@ async function clickResolved(client, resolved, opts = {}) {
17030
17097
  ...hitTestReceiverBackendNodeId === void 0 ? {} : { hitTestReceiverBackendNodeId }
17031
17098
  };
17032
17099
  }
17100
+ async function presentFrameBeforeInput(client) {
17101
+ await client.send("Page.captureScreenshot", {
17102
+ format: "png",
17103
+ clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 }
17104
+ });
17105
+ }
17033
17106
  async function typeText(client, text2) {
17034
17107
  await client.send("Input.insertText", { text: text2 });
17035
17108
  }
@@ -17219,9 +17292,9 @@ async function cmdPageClick(parsed, _args) {
17219
17292
  { settleMs: settle },
17220
17293
  async (client) => {
17221
17294
  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 });
17295
+ const clicked = await clickLiveTarget(live, target, { inspectHitTest: true });
17296
+ if ("ok" in clicked) return { failure: clicked };
17297
+ const dispatch2 = clicked;
17225
17298
  const screenshotResult = await capturePageInputScreenshot(client, "click", target, parsed.noScreenshot, parsed.out);
17226
17299
  return { dispatch: dispatch2, ...screenshotResult };
17227
17300
  }
@@ -228654,6 +228727,19 @@ function spillWholeResult(payload, artifactDir) {
228654
228727
  writePrivateFile(filePath, payload);
228655
228728
  return filePath;
228656
228729
  }
228730
+ function describePageException(details) {
228731
+ const exception = details.exception;
228732
+ if (!exception) return details.text ?? "Unknown error";
228733
+ if (exception.subtype === "error" && exception.description) return exception.description;
228734
+ if (exception.type === "undefined") return "undefined";
228735
+ if ("value" in exception) return JSON.stringify(exception.value);
228736
+ const properties = exception.preview?.properties;
228737
+ if (exception.type === "object" && properties) {
228738
+ const fields = properties.map((p) => `${p.name}: ${p.value ?? "?"}`).join(", ");
228739
+ return `${exception.className ?? "Object"} {${fields}${exception.preview?.overflow ? ", \u2026" : ""}}`;
228740
+ }
228741
+ return exception.description ?? details.text ?? "Unknown error";
228742
+ }
228657
228743
  function isRequestTimeout(error4) {
228658
228744
  return error4 instanceof Error && /CDP request timeout \(\d+ms\): Runtime\.evaluate|collector-host CDP call "Runtime\.evaluate" failed: CDP request timeout/.test(error4.message);
228659
228745
  }
@@ -228799,7 +228885,7 @@ async function cmdPageExec(parsed, _args) {
228799
228885
  );
228800
228886
  }
228801
228887
  if (evalResult.exceptionDetails) {
228802
- const description = evalResult.exceptionDetails.exception?.description ?? "Unknown error";
228888
+ const description = describePageException(evalResult.exceptionDetails);
228803
228889
  return emitExecError(
228804
228890
  parsed,
228805
228891
  "exec_exception",
@@ -229039,6 +229125,29 @@ async function pageBorderRect(client, backendNodeId2) {
229039
229125
  }
229040
229126
  return { ...rect3, x: rect3.x + pageX, y: rect3.y + pageY };
229041
229127
  }
229128
+ async function finishColorTransitions(client) {
229129
+ const tree = await client.send("Page.getFrameTree");
229130
+ if (!tree.frameTree) throw new Error("Page.getFrameTree returned no frames for color-scheme capture.");
229131
+ const frames2 = [tree.frameTree];
229132
+ for (const frame of frames2) {
229133
+ frames2.push(...frame.childFrames ?? []);
229134
+ const world2 = await client.send("Page.createIsolatedWorld", {
229135
+ frameId: frame.frame.id,
229136
+ worldName: "capture-color-scheme"
229137
+ });
229138
+ if (world2.executionContextId === void 0) throw new Error(`No execution context for frame ${frame.frame.id}.`);
229139
+ const result = await client.send("Runtime.evaluate", {
229140
+ contextId: world2.executionContextId,
229141
+ expression: `(() => {
229142
+ document.getAnimations({ subtree: true })
229143
+ .filter(animation => animation instanceof CSSTransition)
229144
+ .forEach(transition => transition.finish());
229145
+ })()`,
229146
+ returnByValue: true
229147
+ });
229148
+ if (!result.result || result.exceptionDetails) throw new Error(`Could not finish CSS transitions in frame ${frame.frame.id}: ${result.exceptionDetails?.text ?? "page evaluation failed"}`);
229149
+ }
229150
+ }
229042
229151
  async function cmdPageShot(parsed, _args) {
229043
229152
  if (parsed.help) {
229044
229153
  console.log(USAGE6);
@@ -229142,7 +229251,12 @@ async function cmdPageShot(parsed, _args) {
229142
229251
  } else if (requestedCrop) {
229143
229252
  cropProvenance = { source: "coordinates", requested: requestedCrop, zoom };
229144
229253
  }
229145
- const screenshot = await captureScreenshotWithCssViewport(client, viewport2, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
229254
+ const screenshot = await captureScreenshotWithCssViewport(client, viewport2, {
229255
+ fullPage: parsed.fullPage,
229256
+ crop: requestedCrop,
229257
+ zoom,
229258
+ ...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
229259
+ });
229146
229260
  const outPath = await resolveOutPath(parsed);
229147
229261
  writeScreenshot(outPath, screenshot.png);
229148
229262
  return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, scrollOffset: screenshot.scrollOffset, crop: cropProvenance };
@@ -229173,7 +229287,7 @@ var init_shot = __esm({
229173
229287
  input:
229174
229288
  --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
229289
  --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
229290
+ --color-scheme <dark|light> transient prefers-color-scheme override for this capture; CSS transitions finish before the shot; cleared afterward
229177
229291
  --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
229292
  --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
229293
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
@@ -229185,7 +229299,7 @@ input:
229185
229299
  output:
229186
229300
  <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
229301
  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.`;
229302
+ 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
229303
  deps4 = { withConnection, nextStepPath, createOneshotSession };
229190
229304
  PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
229191
229305
  LEGIBILITY_SCALE_THRESHOLD = 0.5;
@@ -229909,6 +230023,9 @@ effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, ver
229909
230023
  });
229910
230024
 
229911
230025
  // src/cdp/commands/tab/list.ts
230026
+ function endpointNote(app) {
230027
+ return app !== void 0 && /^arc$/i.test(app.trim()) ? ARC_ENDPOINT_NOTE : void 0;
230028
+ }
229912
230029
  function buildTabsResult(endpoints, unreachable) {
229913
230030
  const totalTabs = endpoints.reduce((n, ep) => n + ep.pages.length, 0);
229914
230031
  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 +230044,8 @@ function buildTabsResult(endpoints, unreachable) {
229927
230044
  const rows = ep.pages.map(
229928
230045
  (t) => line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300))
229929
230046
  );
229930
- return lineList([header, ...rows]);
230047
+ const note = endpointNote(ep.app);
230048
+ return lineList([header, ...rows, ...note ? [note] : []]);
229931
230049
  });
229932
230050
  if (unreachable.length > 0) {
229933
230051
  sections.push(
@@ -229970,8 +230088,10 @@ async function cmdTabList(parsed, _args) {
229970
230088
  if (parsed.port) {
229971
230089
  try {
229972
230090
  const targets = await listTargets(parsed.port);
230091
+ const described = await describeCdpPort(parsed.port).catch(() => null);
229973
230092
  reachable.push({
229974
230093
  port: parsed.port,
230094
+ ...described ? { app: described.app } : {},
229975
230095
  preferred: false,
229976
230096
  ...owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port).pid } : {},
229977
230097
  pages: targets.filter((t) => t.type === "page").map((t) => ({ id: t.id, title: t.title, url: t.url }))
@@ -230003,7 +230123,7 @@ async function cmdTabList(parsed, _args) {
230003
230123
  }
230004
230124
  emitResult(buildTabsResult(reachable, unreachable), { json: parsed.json });
230005
230125
  }
230006
- var USAGE11;
230126
+ var USAGE11, ARC_ENDPOINT_NOTE;
230007
230127
  var init_list = __esm({
230008
230128
  "src/cdp/commands/tab/list.ts"() {
230009
230129
  init_detect();
@@ -230017,6 +230137,7 @@ input:
230017
230137
 
230018
230138
  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
230139
  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.`;
230140
+ 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
230141
  }
230021
230142
  });
230022
230143
 
@@ -230103,9 +230224,9 @@ async function cmdTabClose(parsed, _args) {
230103
230224
  console.log(USAGE13);
230104
230225
  return;
230105
230226
  }
230106
- const target = parsed.positional[0];
230227
+ const target = parsed.positional[0] ?? (parsed.targetSource === "flag" ? parsed.target : void 0);
230107
230228
  if (!target) {
230108
- throw invalidInput("received: no target; expected: capture tab close <target> [--port <port>].", "missing_argument");
230229
+ throw invalidInput("received: no target; expected: capture tab close <target> (or --target <id>) [--port <port>].", "missing_argument");
230109
230230
  }
230110
230231
  if (target.length < 8) {
230111
230232
  throw invalidInput(`received: target ${target}; expected: a full target id or an unambiguous prefix of at least 8 characters.`, "target_prefix_too_short");
@@ -230171,6 +230292,7 @@ var init_close = __esm({
230171
230292
 
230172
230293
  input:
230173
230294
  <target> required \u2014 full tab target id or an unambiguous 8-character prefix
230295
+ --target <id> same as <target>; only an explicit flag counts, never the session's tab or CDP_TARGET
230174
230296
  --port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
230175
230297
 
230176
230298
  output: <tab-closed port=\u2026 target=\u2026> \u2014 the closed tab's full target id and last observed URL.
@@ -267362,7 +267484,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267362
267484
  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
267485
  }
267364
267486
  function printVersion() {
267365
- const declared = "1.4.20";
267487
+ const declared = "1.4.22";
267366
267488
  if (declared) {
267367
267489
  console.log(declared);
267368
267490
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.20",
3
+ "version": "1.4.22",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/cdp/args.ts CHANGED
@@ -272,6 +272,16 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
272
272
  // `quit` takes an optional port; every other tab leaf names exactly one.
273
273
  if (leaf === 'launch' || leaf === 'list') requireCount(values, 0, 0, `tab ${leaf}`);
274
274
  else if (leaf === 'quit') requireCount(values, 0, 1, 'tab quit');
275
+ // `tab close` names its tab either positionally or with an explicit
276
+ // --target flag (the form every follow_up line teaches). Only the flag
277
+ // counts here: session/CDP_TARGET defaults are hydrated later and must
278
+ // never choose which tab a destructive close removes.
279
+ else if (leaf === 'close' && parsed.target !== undefined) {
280
+ requireCount(values, 0, 1, 'tab close');
281
+ if (values.length === 1 && values[0] !== parsed.target) {
282
+ throw schemaInput('tab close', `<target>=${values[0]}, --target=${parsed.target}`, 'one tab: the positional <target> or --target, not two different ids', '<target> or --target');
283
+ }
284
+ }
275
285
  else requireCount(values, 1, 1, `tab ${leaf}`);
276
286
  if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
277
287
  throw schemaInput('tab network', values[0] ?? '(missing)', 'offline or online', '<mode>');
@@ -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
  },
@@ -107,7 +107,39 @@ function spillWholeResult(payload: string, artifactDir?: string): string {
107
107
 
108
108
  interface EvalResult {
109
109
  result?: { value?: unknown };
110
- exceptionDetails?: { exception?: { description?: string } };
110
+ exceptionDetails?: ExceptionDetails;
111
+ }
112
+
113
+ interface ExceptionDetails {
114
+ text?: string;
115
+ exception?: {
116
+ type?: string;
117
+ subtype?: string;
118
+ className?: string;
119
+ value?: unknown;
120
+ description?: string;
121
+ preview?: { properties?: Array<{ name: string; value?: string }>; overflow?: boolean };
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Describes what the page threw. An Error carries its message and stack in
127
+ * `description`; a thrown string, number, or plain object does not, so its
128
+ * value (or its preview's properties) is named instead of falling back to a
129
+ * bare "Object" or "Unknown error".
130
+ */
131
+ function describePageException(details: ExceptionDetails): string {
132
+ const exception = details.exception;
133
+ if (!exception) return details.text ?? 'Unknown error';
134
+ if (exception.subtype === 'error' && exception.description) return exception.description;
135
+ if (exception.type === 'undefined') return 'undefined';
136
+ if ('value' in exception) return JSON.stringify(exception.value);
137
+ const properties = exception.preview?.properties;
138
+ if (exception.type === 'object' && properties) {
139
+ const fields = properties.map((p) => `${p.name}: ${p.value ?? '?'}`).join(', ');
140
+ return `${exception.className ?? 'Object'} {${fields}${exception.preview?.overflow ? ', …' : ''}}`;
141
+ }
142
+ return exception.description ?? details.text ?? 'Unknown error';
111
143
  }
112
144
 
113
145
  /** The structural client surface the focus/evaluation scope drives — the
@@ -316,7 +348,7 @@ export async function cmdPageExec(parsed: ParsedArgs, _args: string[]): Promise<
316
348
  }
317
349
 
318
350
  if (evalResult.exceptionDetails) {
319
- const description = evalResult.exceptionDetails.exception?.description ?? 'Unknown error';
351
+ const description = describePageException(evalResult.exceptionDetails);
320
352
  return emitExecError(
321
353
  parsed,
322
354
  'exec_exception',
@@ -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;
@@ -17,6 +17,7 @@ const USAGE = `capture tab close — close one explicitly identified background
17
17
 
18
18
  input:
19
19
  <target> required — full tab target id or an unambiguous 8-character prefix
20
+ --target <id> same as <target>; only an explicit flag counts, never the session's tab or CDP_TARGET
20
21
  --port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
21
22
 
22
23
  output: <tab-closed port=… target=…> — the closed tab's full target id and last observed URL.
@@ -36,9 +37,9 @@ export async function cmdTabClose(parsed: ParsedArgs, _args: string[]): Promise<
36
37
  return;
37
38
  }
38
39
 
39
- const target = parsed.positional[0];
40
+ const target = parsed.positional[0] ?? (parsed.targetSource === 'flag' ? parsed.target : undefined);
40
41
  if (!target) {
41
- throw invalidInput('received: no target; expected: capture tab close <target> [--port <port>].', 'missing_argument');
42
+ throw invalidInput('received: no target; expected: capture tab close <target> (or --target <id>) [--port <port>].', 'missing_argument');
42
43
  }
43
44
  if (target.length < 8) {
44
45
  throw invalidInput(`received: target ${target}; expected: a full target id or an unambiguous prefix of at least 8 characters.`, 'target_prefix_too_short');
@@ -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',
@@ -403,6 +434,8 @@ export async function clickResolved(
403
434
  if (hitTest.backendNodeId !== backendNodeId || opts.includeHitTestReceiver) hitTestReceiverBackendNodeId = hitTest.backendNodeId;
404
435
  }
405
436
 
437
+ await presentFrameBeforeInput(client);
438
+
406
439
  const pressParams = { type: 'mousePressed', x: dispatchX, y: dispatchY, button: 'left', clickCount: 1 };
407
440
  if (opts.mark !== undefined && client.sendMarked) {
408
441
  await client.sendMarked('Input.dispatchMouseEvent', pressParams, opts.mark);
@@ -421,6 +454,21 @@ export async function clickResolved(
421
454
  };
422
455
  }
423
456
 
457
+ /**
458
+ * Makes the renderer produce a frame before mouse input is dispatched. Arc does
459
+ * not paint a tab opened with `Target.createTarget({ background: true })` until
460
+ * something asks for a frame, and until it paints, `Input.dispatchMouseEvent`
461
+ * returns success while delivering no events to the page. A 1x1 clipped capture
462
+ * is the cheapest request that forces that frame. Chrome, which already paints
463
+ * background targets, only pays the capture.
464
+ */
465
+ async function presentFrameBeforeInput(client: LiveClient): Promise<void> {
466
+ await client.send('Page.captureScreenshot', {
467
+ format: 'png',
468
+ clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 },
469
+ });
470
+ }
471
+
424
472
  /** Types text into the currently focused element via CDP `Input.insertText`. */
425
473
  export async function typeText(client: LiveClient, text: string): Promise<void> {
426
474
  await client.send('Input.insertText', { text });
@@ -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