@crouton-kit/capture 1.4.19 → 1.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/capture +123 -27
- package/package.json +1 -1
- package/src/cdp/commands/page/click.ts +4 -5
- package/src/cdp/commands/page/shot.ts +31 -3
- 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 +37 -6
- package/src/session/commands.ts +29 -0
package/README.md
CHANGED
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,
|
|
@@ -10559,7 +10566,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10559
10566
|
return { entries, incompleteLifecycles };
|
|
10560
10567
|
}
|
|
10561
10568
|
function captureVersion() {
|
|
10562
|
-
const version = "1.4.
|
|
10569
|
+
const version = "1.4.21";
|
|
10563
10570
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10564
10571
|
}
|
|
10565
10572
|
function token() {
|
|
@@ -12661,7 +12668,7 @@ function wallTime(value) {
|
|
|
12661
12668
|
}
|
|
12662
12669
|
}
|
|
12663
12670
|
function captureVersion2() {
|
|
12664
|
-
const version = "1.4.
|
|
12671
|
+
const version = "1.4.21";
|
|
12665
12672
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
12666
12673
|
}
|
|
12667
12674
|
function deepFreeze(value) {
|
|
@@ -15128,6 +15135,15 @@ function rejectSurplusPositionals(parsed, leaf, max, usage) {
|
|
|
15128
15135
|
process.exitCode = 1;
|
|
15129
15136
|
return true;
|
|
15130
15137
|
}
|
|
15138
|
+
async function tabStillListed(port, targetId) {
|
|
15139
|
+
await new Promise((resolve12) => setTimeout(resolve12, 250));
|
|
15140
|
+
try {
|
|
15141
|
+
const found = await startWorld.findTabById(port, targetId);
|
|
15142
|
+
return found !== null && found.type === "page";
|
|
15143
|
+
} catch {
|
|
15144
|
+
return false;
|
|
15145
|
+
}
|
|
15146
|
+
}
|
|
15131
15147
|
async function start(parsed) {
|
|
15132
15148
|
if (parsed.help) {
|
|
15133
15149
|
console.log(START_USAGE);
|
|
@@ -15206,6 +15222,21 @@ async function start(parsed) {
|
|
|
15206
15222
|
const openedTargetId = target.id;
|
|
15207
15223
|
acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
15208
15224
|
pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
|
|
15225
|
+
if (!await tabStillListed(cdpPort, target.id)) {
|
|
15226
|
+
const lostId = target.id;
|
|
15227
|
+
await startWorld.closeTarget(cdpPort, lostId).catch(() => void 0);
|
|
15228
|
+
acquired.pop();
|
|
15229
|
+
target = await startWorld.openTab(cdpPort, url);
|
|
15230
|
+
const retryPort = cdpPort;
|
|
15231
|
+
const retryId = target.id;
|
|
15232
|
+
acquired.push({ label: "opened target", release: () => startWorld.closeTarget(retryPort, retryId) });
|
|
15233
|
+
pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
|
|
15234
|
+
if (!await tabStillListed(cdpPort, target.id)) {
|
|
15235
|
+
throw worldFailure(
|
|
15236
|
+
`Port ${cdpPort} drops tabs created over CDP (${retryId} vanished twice). Use \`capture tab open ${url} --port ${cdpPort}\`, then \`capture session start --port ${cdpPort} --target <id>\`.`
|
|
15237
|
+
);
|
|
15238
|
+
}
|
|
15239
|
+
}
|
|
15209
15240
|
}
|
|
15210
15241
|
}
|
|
15211
15242
|
if (hold) {
|
|
@@ -16422,6 +16453,14 @@ async function connectForCommand(parsed) {
|
|
|
16422
16453
|
const tab = resolved?.tab ?? null;
|
|
16423
16454
|
if (!tab) {
|
|
16424
16455
|
const query = parsed.target ?? parsed.url;
|
|
16456
|
+
if (parsed.targetSource === "session" && activeSession2) {
|
|
16457
|
+
const where = activeSession2.port ? ` on port ${activeSession2.port}` : "";
|
|
16458
|
+
throw captureError(
|
|
16459
|
+
"precondition",
|
|
16460
|
+
"target_unavailable",
|
|
16461
|
+
`Session ${activeSession2.sessionId}'s tab ${query}${where} is no longer listed \u2014 the browser closed or dropped it. Open a replacement with \`capture tab reset <url>${activeSession2.port ? ` --port ${activeSession2.port}` : ""}\` (repoints this session at it), or pick a live tab with \`capture tab list\` and pass \`--target <id>\`.`
|
|
16462
|
+
);
|
|
16463
|
+
}
|
|
16425
16464
|
throw captureError(
|
|
16426
16465
|
"precondition",
|
|
16427
16466
|
"target_unavailable",
|
|
@@ -16984,9 +17023,20 @@ async function axIdentityFor(client, backendNodeId2) {
|
|
|
16984
17023
|
return { role: null, name: null };
|
|
16985
17024
|
}
|
|
16986
17025
|
}
|
|
17026
|
+
async function clickLiveTarget(client, input, opts = {}) {
|
|
17027
|
+
const initial = await resolveLiveTarget(client, input);
|
|
17028
|
+
if (!initial.ok) return initial;
|
|
17029
|
+
await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: initial.backendNodeId });
|
|
17030
|
+
const current = await resolveLiveTarget(client, input);
|
|
17031
|
+
if (!current.ok) return current;
|
|
17032
|
+
return dispatchResolvedClick(client, current, opts);
|
|
17033
|
+
}
|
|
16987
17034
|
async function clickResolved(client, resolved, opts = {}) {
|
|
17035
|
+
await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: resolved.backendNodeId });
|
|
17036
|
+
return dispatchResolvedClick(client, resolved, opts);
|
|
17037
|
+
}
|
|
17038
|
+
async function dispatchResolvedClick(client, resolved, opts) {
|
|
16988
17039
|
const { backendNodeId: backendNodeId2 } = resolved;
|
|
16989
|
-
await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: backendNodeId2 });
|
|
16990
17040
|
const boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
|
|
16991
17041
|
const quad = boxResponse?.model?.content;
|
|
16992
17042
|
if (!isContentQuad(quad)) {
|
|
@@ -17003,7 +17053,18 @@ async function clickResolved(client, resolved, opts = {}) {
|
|
|
17003
17053
|
const dispatchY = Math.round(y);
|
|
17004
17054
|
let hitTestReceiverBackendNodeId;
|
|
17005
17055
|
if (opts.inspectHitTest) {
|
|
17006
|
-
const
|
|
17056
|
+
const metrics = await client.send("Page.getLayoutMetrics");
|
|
17057
|
+
const viewport2 = metrics.cssVisualViewport;
|
|
17058
|
+
if (!viewport2 || !Number.isFinite(viewport2.pageX) || !Number.isFinite(viewport2.pageY)) {
|
|
17059
|
+
throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
|
|
17060
|
+
method: "Page.getLayoutMetrics",
|
|
17061
|
+
response: metrics
|
|
17062
|
+
});
|
|
17063
|
+
}
|
|
17064
|
+
const hitTest = await client.send("DOM.getNodeForLocation", {
|
|
17065
|
+
x: Math.round(x + viewport2.pageX),
|
|
17066
|
+
y: Math.round(y + viewport2.pageY)
|
|
17067
|
+
});
|
|
17007
17068
|
if (typeof hitTest.backendNodeId !== "number" || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
|
|
17008
17069
|
throw captureError(
|
|
17009
17070
|
"world",
|
|
@@ -17219,9 +17280,9 @@ async function cmdPageClick(parsed, _args) {
|
|
|
17219
17280
|
{ settleMs: settle },
|
|
17220
17281
|
async (client) => {
|
|
17221
17282
|
const live = client;
|
|
17222
|
-
const
|
|
17223
|
-
if (
|
|
17224
|
-
const dispatch2 =
|
|
17283
|
+
const clicked = await clickLiveTarget(live, target, { inspectHitTest: true });
|
|
17284
|
+
if ("ok" in clicked) return { failure: clicked };
|
|
17285
|
+
const dispatch2 = clicked;
|
|
17225
17286
|
const screenshotResult = await capturePageInputScreenshot(client, "click", target, parsed.noScreenshot, parsed.out);
|
|
17226
17287
|
return { dispatch: dispatch2, ...screenshotResult };
|
|
17227
17288
|
}
|
|
@@ -229039,6 +229100,29 @@ async function pageBorderRect(client, backendNodeId2) {
|
|
|
229039
229100
|
}
|
|
229040
229101
|
return { ...rect3, x: rect3.x + pageX, y: rect3.y + pageY };
|
|
229041
229102
|
}
|
|
229103
|
+
async function finishColorTransitions(client) {
|
|
229104
|
+
const tree = await client.send("Page.getFrameTree");
|
|
229105
|
+
if (!tree.frameTree) throw new Error("Page.getFrameTree returned no frames for color-scheme capture.");
|
|
229106
|
+
const frames2 = [tree.frameTree];
|
|
229107
|
+
for (const frame of frames2) {
|
|
229108
|
+
frames2.push(...frame.childFrames ?? []);
|
|
229109
|
+
const world2 = await client.send("Page.createIsolatedWorld", {
|
|
229110
|
+
frameId: frame.frame.id,
|
|
229111
|
+
worldName: "capture-color-scheme"
|
|
229112
|
+
});
|
|
229113
|
+
if (world2.executionContextId === void 0) throw new Error(`No execution context for frame ${frame.frame.id}.`);
|
|
229114
|
+
const result = await client.send("Runtime.evaluate", {
|
|
229115
|
+
contextId: world2.executionContextId,
|
|
229116
|
+
expression: `(() => {
|
|
229117
|
+
document.getAnimations({ subtree: true })
|
|
229118
|
+
.filter(animation => animation instanceof CSSTransition)
|
|
229119
|
+
.forEach(transition => transition.finish());
|
|
229120
|
+
})()`,
|
|
229121
|
+
returnByValue: true
|
|
229122
|
+
});
|
|
229123
|
+
if (!result.result || result.exceptionDetails) throw new Error(`Could not finish CSS transitions in frame ${frame.frame.id}: ${result.exceptionDetails?.text ?? "page evaluation failed"}`);
|
|
229124
|
+
}
|
|
229125
|
+
}
|
|
229042
229126
|
async function cmdPageShot(parsed, _args) {
|
|
229043
229127
|
if (parsed.help) {
|
|
229044
229128
|
console.log(USAGE6);
|
|
@@ -229142,7 +229226,12 @@ async function cmdPageShot(parsed, _args) {
|
|
|
229142
229226
|
} else if (requestedCrop) {
|
|
229143
229227
|
cropProvenance = { source: "coordinates", requested: requestedCrop, zoom };
|
|
229144
229228
|
}
|
|
229145
|
-
const screenshot = await captureScreenshotWithCssViewport(client, viewport2, {
|
|
229229
|
+
const screenshot = await captureScreenshotWithCssViewport(client, viewport2, {
|
|
229230
|
+
fullPage: parsed.fullPage,
|
|
229231
|
+
crop: requestedCrop,
|
|
229232
|
+
zoom,
|
|
229233
|
+
...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
|
|
229234
|
+
});
|
|
229146
229235
|
const outPath = await resolveOutPath(parsed);
|
|
229147
229236
|
writeScreenshot(outPath, screenshot.png);
|
|
229148
229237
|
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, scrollOffset: screenshot.scrollOffset, crop: cropProvenance };
|
|
@@ -229173,7 +229262,7 @@ var init_shot = __esm({
|
|
|
229173
229262
|
input:
|
|
229174
229263
|
--viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace \u2014 preset names are not accepted. Absent \u2192 no emulation, the browser's actual current viewport is captured
|
|
229175
229264
|
--full-page transient override to the full scrollable content height for this capture
|
|
229176
|
-
--color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
|
|
229265
|
+
--color-scheme <dark|light> transient prefers-color-scheme override for this capture; CSS transitions finish before the shot; cleared afterward
|
|
229177
229266
|
--crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
|
|
229178
229267
|
--crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
|
|
229179
229268
|
--pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
|
|
@@ -229185,7 +229274,7 @@ input:
|
|
|
229185
229274
|
output:
|
|
229186
229275
|
<screenshot path=\u2026 width=\u2026 height=\u2026 css-width=\u2026 css-height=\u2026 css-x=\u2026 css-y=\u2026 css-to-image-x=\u2026 css-to-image-y=\u2026 effective-downscale-x=\u2026 effective-downscale-y=\u2026 scroll-x=\u2026 scroll-y=\u2026 emulation=none|viewport|full-page color-scheme=dark|light> \u2014 saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
|
|
229187
229276
|
effects:
|
|
229188
|
-
no flags: none \u2014 the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture \u2014 two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears
|
|
229277
|
+
no flags: none \u2014 the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture \u2014 two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override, finishes CSS transitions before capture, and clears the override afterward. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
|
|
229189
229278
|
deps4 = { withConnection, nextStepPath, createOneshotSession };
|
|
229190
229279
|
PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
229191
229280
|
LEGIBILITY_SCALE_THRESHOLD = 0.5;
|
|
@@ -229909,6 +229998,9 @@ effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, ver
|
|
|
229909
229998
|
});
|
|
229910
229999
|
|
|
229911
230000
|
// src/cdp/commands/tab/list.ts
|
|
230001
|
+
function endpointNote(app) {
|
|
230002
|
+
return app !== void 0 && /^arc$/i.test(app.trim()) ? ARC_ENDPOINT_NOTE : void 0;
|
|
230003
|
+
}
|
|
229912
230004
|
function buildTabsResult(endpoints, unreachable) {
|
|
229913
230005
|
const totalTabs = endpoints.reduce((n, ep) => n + ep.pages.length, 0);
|
|
229914
230006
|
let summary = endpoints.length === 0 && unreachable.length === 0 ? text`0 CDP endpoints found listening on localhost.` : fact`${totalTabs} page tab(s) on ${endpoints.length} reachable CDP endpoint(s).`;
|
|
@@ -229927,7 +230019,8 @@ function buildTabsResult(endpoints, unreachable) {
|
|
|
229927
230019
|
const rows = ep.pages.map(
|
|
229928
230020
|
(t) => line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300))
|
|
229929
230021
|
);
|
|
229930
|
-
|
|
230022
|
+
const note = endpointNote(ep.app);
|
|
230023
|
+
return lineList([header, ...rows, ...note ? [note] : []]);
|
|
229931
230024
|
});
|
|
229932
230025
|
if (unreachable.length > 0) {
|
|
229933
230026
|
sections.push(
|
|
@@ -229970,8 +230063,10 @@ async function cmdTabList(parsed, _args) {
|
|
|
229970
230063
|
if (parsed.port) {
|
|
229971
230064
|
try {
|
|
229972
230065
|
const targets = await listTargets(parsed.port);
|
|
230066
|
+
const described = await describeCdpPort(parsed.port).catch(() => null);
|
|
229973
230067
|
reachable.push({
|
|
229974
230068
|
port: parsed.port,
|
|
230069
|
+
...described ? { app: described.app } : {},
|
|
229975
230070
|
preferred: false,
|
|
229976
230071
|
...owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port).pid } : {},
|
|
229977
230072
|
pages: targets.filter((t) => t.type === "page").map((t) => ({ id: t.id, title: t.title, url: t.url }))
|
|
@@ -230003,7 +230098,7 @@ async function cmdTabList(parsed, _args) {
|
|
|
230003
230098
|
}
|
|
230004
230099
|
emitResult(buildTabsResult(reachable, unreachable), { json: parsed.json });
|
|
230005
230100
|
}
|
|
230006
|
-
var USAGE11;
|
|
230101
|
+
var USAGE11, ARC_ENDPOINT_NOTE;
|
|
230007
230102
|
var init_list = __esm({
|
|
230008
230103
|
"src/cdp/commands/tab/list.ts"() {
|
|
230009
230104
|
init_detect();
|
|
@@ -230017,6 +230112,7 @@ input:
|
|
|
230017
230112
|
|
|
230018
230113
|
output: <tabs endpoints=\u2026 tabs=\u2026> \u2014 one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
230019
230114
|
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle \u2014 browsers capture did not start are never signalled.`;
|
|
230115
|
+
ARC_ENDPOINT_NOTE = text` note: Arc exposes only tabs opened over CDP as page targets; the tabs in its windows are not listed. Open one with "capture tab open <url> --port <port>", then "capture session start --port <port> --target <id>".`;
|
|
230020
230116
|
}
|
|
230021
230117
|
});
|
|
230022
230118
|
|
|
@@ -267362,7 +267458,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
|
|
|
267362
267458
|
A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser \u2014 nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands \u2014 precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target.`;
|
|
267363
267459
|
}
|
|
267364
267460
|
function printVersion() {
|
|
267365
|
-
const declared = "1.4.
|
|
267461
|
+
const declared = "1.4.21";
|
|
267366
267462
|
if (declared) {
|
|
267367
267463
|
console.log(declared);
|
|
267368
267464
|
return;
|
package/package.json
CHANGED
|
@@ -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
|
},
|
|
@@ -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;
|
|
@@ -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',
|
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
|
|