@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 +153 -31
- package/package.json +1 -1
- package/src/cdp/args.ts +10 -0
- package/src/cdp/commands/page/click.ts +4 -5
- package/src/cdp/commands/page/exec.ts +34 -2
- package/src/cdp/commands/page/shot.ts +31 -3
- package/src/cdp/commands/tab/close.ts +3 -2
- package/src/cdp/commands/tab/list.ts +12 -1
- package/src/cdp/connection.ts +8 -0
- package/src/cdp/detect.ts +22 -15
- package/src/cdp/screenshot.ts +2 -1
- package/src/interact.ts +54 -6
- package/src/session/commands.ts +29 -0
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
|
-
|
|
3919
|
-
const
|
|
3920
|
-
if (
|
|
3921
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
17223
|
-
if (
|
|
17224
|
-
const dispatch2 =
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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.
|
|
267487
|
+
const declared = "1.4.22";
|
|
267366
267488
|
if (declared) {
|
|
267367
267489
|
console.log(declared);
|
|
267368
267490
|
return;
|
package/package.json
CHANGED
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
|
-
|
|
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
|
|
210
|
-
if (
|
|
211
|
-
const dispatch =
|
|
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?:
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
package/src/cdp/connection.ts
CHANGED
|
@@ -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
|
-
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
|
|
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
|
|
package/src/cdp/screenshot.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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 });
|
package/src/session/commands.ts
CHANGED
|
@@ -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
|
|