@crouton-kit/capture 1.4.2 → 1.4.11
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 +187 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
package/src/cdp/bridge/spawn.ts
CHANGED
|
@@ -8,42 +8,36 @@ import { spawn } from 'child_process';
|
|
|
8
8
|
import * as crypto from 'crypto';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { CAPTURE_ROOT
|
|
11
|
+
import { CAPTURE_ROOT } from '../../session/artifacts.js';
|
|
12
12
|
|
|
13
13
|
export function bridgeSocketPath(sessionDir: string): string {
|
|
14
14
|
return path.join(sessionDir, 'bridge.sock');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* combined with `os.tmpdir()`'s own (often long, per-user) prefix can already exceed that before
|
|
22
|
-
* adding a filename, so the socket can never live inside `recDir` — see `recorderSocketPath()`.
|
|
18
|
+
* The collector host socket is stored in a short private directory because
|
|
19
|
+
* macOS caps `AF_UNIX` paths at about 104 bytes. This cannot live beneath
|
|
20
|
+
* CAPTURE_ROOT: test and caller roots can already consume that entire budget.
|
|
23
21
|
*/
|
|
24
|
-
function
|
|
25
|
-
|
|
22
|
+
function collectorSocketDir(): string {
|
|
23
|
+
const dir = '/tmp/capture-sockets';
|
|
24
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
26
|
+
}
|
|
27
|
+
const stat = fs.lstatSync(dir);
|
|
28
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`collector socket directory is not a real directory: ${dir}`);
|
|
29
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw new Error(`collector socket directory is not owned by this user: ${dir}`);
|
|
30
|
+
fs.chmodSync(dir, 0o700);
|
|
31
|
+
return dir;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* own length/depth), so the resulting socket path stays bounded regardless of how deep the
|
|
31
|
-
* recording's session/motion/recs nesting is.
|
|
32
|
-
*/
|
|
33
|
-
function shortSocketName(recDir: string): string {
|
|
34
|
-
return crypto.createHash('sha1').update(path.resolve(recDir)).digest('hex').slice(0, 16);
|
|
34
|
+
function shortSocketName(scopeDir: string): string {
|
|
35
|
+
return crypto.createHash('sha1').update(path.resolve(scopeDir)).digest('hex').slice(0, 16);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* so binding it never risks exceeding the platform's `AF_UNIX` pathname limit. `recDir` is still
|
|
41
|
-
* required by every recorder-mode caller (spawn args, artifact writers); this function only
|
|
42
|
-
* derives the socket's own location from it, deterministically, without embedding `recDir` in
|
|
43
|
-
* the path itself.
|
|
44
|
-
*/
|
|
45
|
-
export function recorderSocketPath(recDir: string): string {
|
|
46
|
-
return path.join(recorderSocketDir(), `${shortSocketName(recDir)}.sock`);
|
|
38
|
+
/** The one collector host socket is keyed by its session, not an individual artifact. */
|
|
39
|
+
export function collectorHostSocketPath(sessionDir: string): string {
|
|
40
|
+
return path.join(collectorSocketDir(), `host-${shortSocketName(sessionDir)}.sock`);
|
|
47
41
|
}
|
|
48
42
|
|
|
49
43
|
export async function startBridge(
|
|
@@ -85,6 +79,27 @@ export async function startBridge(
|
|
|
85
79
|
return { socketPath, pid };
|
|
86
80
|
}
|
|
87
81
|
|
|
82
|
+
export async function startCollectorHost(
|
|
83
|
+
socketPath: string,
|
|
84
|
+
port: number,
|
|
85
|
+
targetId: string,
|
|
86
|
+
sessionDir: string,
|
|
87
|
+
timeoutMs = 5000,
|
|
88
|
+
): Promise<{ socketPath: string; pid: number }> {
|
|
89
|
+
const child = spawn(process.execPath, [process.argv[1], '__bridge-serve', '--socket', socketPath, '--port', String(port), '--target', targetId, 'host', sessionDir], { detached: true, stdio: 'ignore' });
|
|
90
|
+
child.unref();
|
|
91
|
+
if (!child.pid) throw new Error('Failed to spawn collector host process.');
|
|
92
|
+
const started = Date.now();
|
|
93
|
+
while (!fs.existsSync(socketPath)) {
|
|
94
|
+
if (Date.now() - started > timeoutMs) {
|
|
95
|
+
stopBridge(child.pid, socketPath);
|
|
96
|
+
throw new Error(`Collector host (pid ${child.pid}) did not come up within ${timeoutMs}ms.`);
|
|
97
|
+
}
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
99
|
+
}
|
|
100
|
+
return { socketPath, pid: child.pid };
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
export function stopBridge(pid: number | null | undefined, socketPath: string | null | undefined): void {
|
|
89
104
|
if (pid) {
|
|
90
105
|
try {
|
|
@@ -95,80 +110,9 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
|
|
|
95
110
|
}
|
|
96
111
|
if (socketPath) {
|
|
97
112
|
try {
|
|
98
|
-
fs.unlinkSync(socketPath);
|
|
113
|
+
if (fs.lstatSync(socketPath).isSocket()) fs.unlinkSync(socketPath);
|
|
99
114
|
} catch {
|
|
100
115
|
// Already gone.
|
|
101
116
|
}
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Spawns the same detached `__bridge-serve` entrypoint in recorder mode:
|
|
107
|
-
* it connects to one tab (`targetId`) instead of the browser level, and
|
|
108
|
-
* drives `capture motion rec`'s screencast/tracing/observers instead of
|
|
109
|
-
* proxying arbitrary `--browser` CDP calls. Mirrors `startBridge()`'s
|
|
110
|
-
* spawn + "wait for the socket file to appear" pattern; the caller
|
|
111
|
-
* (U14's lifecycle routing) tears it down with the same `stopBridge()`
|
|
112
|
-
* used for the plain held bridge.
|
|
113
|
-
*
|
|
114
|
-
* Recorder mode is selected via a positional (`recorder <recDir> <harId>`)
|
|
115
|
-
* rather than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is
|
|
116
|
-
* not owned by this unit, but `positional` is already generic passthrough.
|
|
117
|
-
*
|
|
118
|
-
* `harId` is the owning session's live HAR recording id (`.session.json`'s
|
|
119
|
-
* `harId`) — the recorder bridge installs a streaming `HARRecorder` on its
|
|
120
|
-
* held tab connection and appends network evidence to that store, so the id
|
|
121
|
-
* must travel to the spawned process. Required: a session-backed recorder
|
|
122
|
-
* always has one (the caller throws before spawning if it doesn't).
|
|
123
|
-
*
|
|
124
|
-
* The socket-file readiness poll below doubles as the nonce-boot-file
|
|
125
|
-
* guarantee: the recorder writes `{recDir}/recorder-nonce.json` BEFORE it
|
|
126
|
-
* binds the socket (see `../recorder-bridge.ts`), so once this resolves the
|
|
127
|
-
* caller can read the nonce without racing the child.
|
|
128
|
-
*/
|
|
129
|
-
export async function startRecorderBridge(
|
|
130
|
-
socketPath: string,
|
|
131
|
-
port: number,
|
|
132
|
-
targetId: string,
|
|
133
|
-
recDir: string,
|
|
134
|
-
harId: string,
|
|
135
|
-
timeoutMs = 5000,
|
|
136
|
-
): Promise<{ socketPath: string; pid: number }> {
|
|
137
|
-
const scriptPath = process.argv[1];
|
|
138
|
-
const child = spawn(
|
|
139
|
-
process.execPath,
|
|
140
|
-
[
|
|
141
|
-
scriptPath,
|
|
142
|
-
'__bridge-serve',
|
|
143
|
-
'--socket',
|
|
144
|
-
socketPath,
|
|
145
|
-
'--port',
|
|
146
|
-
String(port),
|
|
147
|
-
'--target',
|
|
148
|
-
targetId,
|
|
149
|
-
'recorder',
|
|
150
|
-
recDir,
|
|
151
|
-
harId,
|
|
152
|
-
],
|
|
153
|
-
{ detached: true, stdio: 'ignore' },
|
|
154
|
-
);
|
|
155
|
-
child.unref();
|
|
156
|
-
const pid = child.pid;
|
|
157
|
-
if (!pid) {
|
|
158
|
-
throw new Error('Failed to spawn recorder bridge process.');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const start = Date.now();
|
|
162
|
-
while (!fs.existsSync(socketPath)) {
|
|
163
|
-
if (Date.now() - start > timeoutMs) {
|
|
164
|
-
stopBridge(pid, socketPath);
|
|
165
|
-
throw new Error(
|
|
166
|
-
`Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
|
|
167
|
-
`Check that target "${targetId}" is reachable on port ${port}.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return { socketPath, pid };
|
|
174
|
-
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { runBridgeServer } from '../bridge/server.js';
|
|
2
|
-
import {
|
|
2
|
+
import { runCollectorHost } from '../host/server.js';
|
|
3
3
|
import { invalidInput } from '../../errors.js';
|
|
4
4
|
import { type ParsedArgs } from '../types.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Hidden entrypoint spawned detached by `capture session start --hold`
|
|
8
8
|
* (see `bridge/spawn.ts`'s `startBridge()`) or by `capture motion rec
|
|
9
|
-
* --start` in
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* rather than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't
|
|
14
|
-
* owned by this unit but `parsed.positional` is already generic passthrough:
|
|
15
|
-
* capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>
|
|
9
|
+
* --start` in collector-host mode. Not documented in `capture --help` —
|
|
10
|
+
* nothing calls this directly. Collector-host mode is selected by the
|
|
11
|
+
* `host <sessionDir>` positional:
|
|
12
|
+
* capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> host <sessionDir>
|
|
16
13
|
*
|
|
17
14
|
* Usage errors throw the repo's structured `CaptureError` — the root
|
|
18
15
|
* boundary in `src/capture.ts` renders it via `failureResult()` and exits
|
|
@@ -21,24 +18,17 @@ import { type ParsedArgs } from '../types.js';
|
|
|
21
18
|
export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
22
19
|
if (!parsed.socket) {
|
|
23
20
|
throw invalidInput(
|
|
24
|
-
'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId>
|
|
21
|
+
'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId>] [host <sessionDir>]',
|
|
25
22
|
'bridge_serve_usage',
|
|
26
23
|
);
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
if (parsed.positional[0] === '
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
throw invalidInput(
|
|
34
|
-
'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>',
|
|
35
|
-
'bridge_serve_usage',
|
|
36
|
-
);
|
|
26
|
+
if (parsed.positional[0] === 'host') {
|
|
27
|
+
const sessionDir = parsed.positional[1];
|
|
28
|
+
if (!parsed.target || !sessionDir || parsed.port === undefined) {
|
|
29
|
+
throw invalidInput('Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> host <sessionDir>', 'bridge_serve_usage');
|
|
37
30
|
}
|
|
38
|
-
await
|
|
39
|
-
// Deliberately does not exit: the open Unix socket server and the live
|
|
40
|
-
// tab websocket keep this detached process alive until an authenticated
|
|
41
|
-
// `rec-stop` completes (self-exit) or the caller escalates with SIGTERM.
|
|
31
|
+
await runCollectorHost({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, sessionDir });
|
|
42
32
|
return;
|
|
43
33
|
}
|
|
44
34
|
|
package/src/cdp/commands/cdp.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { connectForCommand } from '../connection.js';
|
|
|
24
24
|
import { getBrowserClient, findTabById } from '../targets.js';
|
|
25
25
|
import { detectCdpPort } from '../detect.js';
|
|
26
26
|
import { sendBridgeRequest } from '../bridge/client.js';
|
|
27
|
-
import {
|
|
27
|
+
import { isMotionHeldClient } from '../host/held-client.js';
|
|
28
28
|
import { type ParsedArgs } from '../types.js';
|
|
29
29
|
import {
|
|
30
30
|
capped,
|
|
@@ -45,8 +45,7 @@ const GENEROUS_RESULT_CAP = 4000;
|
|
|
45
45
|
/** Root-help representation of this leaf, assembled by `src/capture.ts`. */
|
|
46
46
|
export const COMMAND_BLOCK = `<command name="cdp">
|
|
47
47
|
raw CDP escape hatch — send any protocol method capture doesn't wrap, or wait for a protocol event
|
|
48
|
-
use when
|
|
49
|
-
cdp [<Domain.method>] [--params <json>] [--browser] [--wait-event <name>] — \`capture cdp -h\`
|
|
48
|
+
use when sending a CDP method or waiting for an event Capture does not otherwise expose
|
|
50
49
|
</command>`;
|
|
51
50
|
|
|
52
51
|
const HELP = `capture cdp — send a raw CDP protocol method and/or wait for a protocol event: the escape hatch for domains no other capture command wraps (Browser.*, ServiceWorker.*, Target.*, ...).
|
|
@@ -60,6 +59,9 @@ Input:
|
|
|
60
59
|
--target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
|
|
61
60
|
--timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
|
|
62
61
|
|
|
62
|
+
Connection lifetime:
|
|
63
|
+
Unless --browser routes through a held session, each invocation opens an isolated CDP connection and closes it when the command exits. Domain enables and remote object handles (objectIds) are connection-scoped, so a later one-shot invocation cannot use either. A multi-step protocol sequence needs connection state that survives between its calls.
|
|
64
|
+
|
|
63
65
|
Output:
|
|
64
66
|
<cdp-result method=… wait-event=… scope=…> — the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
|
|
65
67
|
|
|
@@ -68,7 +70,7 @@ Effects:
|
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* The minimal client surface both scopes drive. Satisfied structurally by
|
|
71
|
-
* `CDPClient`, `
|
|
73
|
+
* `CDPClient`, `MotionHeldClient`, and test stubs \u2014 the injectable
|
|
72
74
|
* `connect` parameter on `runPageScope` exists so tests can exercise the
|
|
73
75
|
* command wiring against a stub without a live browser.
|
|
74
76
|
*/
|
|
@@ -145,8 +147,15 @@ const TAB_SCOPE_RECOVERY =
|
|
|
145
147
|
'Many CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are tab-scoped, not connection-scoped — re-run with --target <tabId> (`capture tab list` shows available tabs).';
|
|
146
148
|
|
|
147
149
|
/** Appends the tab-scope recovery hint when the failed call was target-less. */
|
|
148
|
-
function
|
|
149
|
-
return
|
|
150
|
+
function timeoutGuidance(message: string, timeoutMs: number): string {
|
|
151
|
+
return /\b(?:timed out|timeout)\b/i.test(message)
|
|
152
|
+
? `${message} The request limit was ${timeoutMs}ms; re-run with \`--timeout <ms>\` to extend it.`
|
|
153
|
+
: message;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cdpFailed(message: string, flagTarget: string | undefined, timeoutMs: number, cause?: unknown): CaptureError {
|
|
157
|
+
const detailed = timeoutGuidance(message, timeoutMs);
|
|
158
|
+
return captureError('world', 'cdp_failed', flagTarget ? detailed : `${detailed} ${TAB_SCOPE_RECOVERY}`, cause);
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
@@ -193,7 +202,7 @@ export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void>
|
|
|
193
202
|
throw captureError(
|
|
194
203
|
'world',
|
|
195
204
|
'cdp_failed',
|
|
196
|
-
`\`${invocationLabel(method, parsed.waitEvent)}\` failed on the page connection: ${err instanceof Error ? err.message : String(err)}`,
|
|
205
|
+
timeoutGuidance(`\`${invocationLabel(method, parsed.waitEvent)}\` failed on the page connection: ${err instanceof Error ? err.message : String(err)}`, timeoutMs),
|
|
197
206
|
err,
|
|
198
207
|
);
|
|
199
208
|
}
|
|
@@ -224,17 +233,29 @@ export async function runBrowserScope(
|
|
|
224
233
|
const bridgeSocket = session?.bridgeSocket;
|
|
225
234
|
const heldBridgeActive = Boolean(bridgeSocket && fs.existsSync(bridgeSocket));
|
|
226
235
|
if (parsed.portSource !== 'flag' && bridgeSocket && heldBridgeActive) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
let resp;
|
|
237
|
+
try {
|
|
238
|
+
resp = await deps.sendBridgeRequest(bridgeSocket, {
|
|
239
|
+
method,
|
|
240
|
+
params,
|
|
241
|
+
targetId: flagTarget,
|
|
242
|
+
waitEvent: parsed.waitEvent,
|
|
243
|
+
timeoutMs,
|
|
244
|
+
});
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof CaptureError) throw err;
|
|
247
|
+
throw cdpFailed(
|
|
248
|
+
`\`${invocationLabel(method, parsed.waitEvent)}\` failed over the held connection: ${err instanceof Error ? err.message : String(err)}`,
|
|
249
|
+
flagTarget,
|
|
250
|
+
timeoutMs,
|
|
251
|
+
err,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
234
254
|
if (!resp.ok) {
|
|
235
255
|
throw cdpFailed(
|
|
236
256
|
`\`${invocationLabel(method, parsed.waitEvent)}\` failed over the held connection: ${resp.error ?? 'unknown bridge error'}`,
|
|
237
257
|
flagTarget,
|
|
258
|
+
timeoutMs,
|
|
238
259
|
);
|
|
239
260
|
}
|
|
240
261
|
emitCdpResult({
|
|
@@ -299,6 +320,7 @@ export async function runBrowserScope(
|
|
|
299
320
|
throw cdpFailed(
|
|
300
321
|
`\`${invocationLabel(method, parsed.waitEvent)}\` failed on the one-shot browser connection: ${err instanceof Error ? err.message : String(err)}`,
|
|
301
322
|
flagTarget,
|
|
323
|
+
timeoutMs,
|
|
302
324
|
err,
|
|
303
325
|
);
|
|
304
326
|
} finally {
|
|
@@ -309,7 +331,7 @@ export async function runBrowserScope(
|
|
|
309
331
|
/**
|
|
310
332
|
* Exported for testing (`test/recorder-navigate-waitevent.test.ts`,
|
|
311
333
|
* `test/cdp-command.test.ts`): the recorder-held branch below
|
|
312
|
-
* (`
|
|
334
|
+
* (`isMotionHeldClient(client)`) is the actual command wiring
|
|
313
335
|
* `capture cdp --wait-event` runs under an active recording, and the
|
|
314
336
|
* injectable `connect` lets `test/cdp-command.test.ts` drive the non-held
|
|
315
337
|
* branch against a stub client.
|
|
@@ -325,17 +347,17 @@ export async function runPageScope(
|
|
|
325
347
|
try {
|
|
326
348
|
let result: unknown;
|
|
327
349
|
let event: unknown;
|
|
328
|
-
if (
|
|
329
|
-
// The
|
|
350
|
+
if (isMotionHeldClient(client)) {
|
|
351
|
+
// The motion-held adapter's `.on()` is a documented no-op (nothing
|
|
330
352
|
// pushes unsolicited events back over its one-request-one-response
|
|
331
353
|
// socket) — `waitForEventOnce` below would hang until its own timeout.
|
|
332
354
|
// `.waitEvent()`/`.dispatch()` are the real event-wait surface for this
|
|
333
|
-
// adapter, routing the wait through the
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
355
|
+
// adapter, routing the wait through the collector host's event broker.
|
|
356
|
+
// When both a method and a wait-event are requested, `.dispatch()`
|
|
357
|
+
// carries them in ONE request so the host arms the wait before
|
|
358
|
+
// dispatching the call — sending them as two separate requests
|
|
337
359
|
// (`.send()` then `.waitEvent()`) risks the action firing the event
|
|
338
|
-
// before the wait-only request even reaches the
|
|
360
|
+
// before the wait-only request even reaches the host. This command has
|
|
339
361
|
// no fragment-nav-style multi-call logic and already throws on an
|
|
340
362
|
// event-wait timeout, so one combined request preserves the complete
|
|
341
363
|
// result contract (contrast `page/navigate.ts`'s multi-call
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { capped, emitResult, fact, formatArtifactList, lineList, text } from '../../../output/render.js';
|
|
3
|
+
import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
|
|
4
|
+
import { selectRecords } from '../../../output/selection.js';
|
|
5
|
+
|
|
6
|
+
const HELP = `capture heap census <snapshot> [--axis constructor|string] [--limit <N>] — what the heap is made of
|
|
7
|
+
|
|
8
|
+
input:
|
|
9
|
+
<snapshot> heap snapshot id in the active session or an absolute snapshot path (required)
|
|
10
|
+
--axis <axis> constructor (default) groups every node by constructor name with node count and retained bytes; string groups equal-content strings with their instance count and estimated duplicate bytes
|
|
11
|
+
--limit <N> return the top N groups by retained bytes; default 25, including under --json
|
|
12
|
+
output: <heap-census …> — one row per group with its counts and sizes, plus the size qualification for the chosen axis; --json mirrors
|
|
13
|
+
effects: read-only — reads the finalized snapshot artifact, never drives the browser`;
|
|
14
|
+
|
|
15
|
+
export function cmdHeapCensus(parsed: ParsedArgs): void {
|
|
16
|
+
if (parsed.help) {
|
|
17
|
+
console.log(HELP);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const ref = resolveHeapRef(parsed.positional[0]);
|
|
21
|
+
const heap = loadHeap(ref);
|
|
22
|
+
const limit = parsed.limit ?? 25;
|
|
23
|
+
const axis = parsed.axis ?? 'constructor';
|
|
24
|
+
if (axis === 'string') {
|
|
25
|
+
const duplicate = heap.duplicateStrings();
|
|
26
|
+
const groups = [...duplicate.duplicates].sort((a, b) => b.wastedBytes - a.wastedBytes || b.totalSelfBytes - a.totalSelfBytes || a.value.localeCompare(b.value));
|
|
27
|
+
const displayed = selectRecords(groups, parsed, limit);
|
|
28
|
+
emitResult({
|
|
29
|
+
tag: 'heap-census',
|
|
30
|
+
attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), axis, groups: groups.length, displayed: displayed.length, nodes: heap.nodeCount, 'size-qualification': duplicate.sizeQualification },
|
|
31
|
+
summary: text`Equal-content string instances grouped from the Chrome heap snapshot.`,
|
|
32
|
+
artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
|
|
33
|
+
sections: [fact`${duplicate.sizeQualification}`, lineList(displayed.map((group, index) => fact`${index + 1}. ${capped(group.value, 120)} · instances=${group.count} self-bytes=${group.totalSelfBytes} wasted-bytes=${group.wastedBytes}`))],
|
|
34
|
+
jsonSections: displayed.map(group => ({ string: capped(group.value, Number.MAX_SAFE_INTEGER), instanceCount: group.count, selfBytes: group.totalSelfBytes, wastedBytes: group.wastedBytes, sizeQualification: duplicate.sizeQualification })),
|
|
35
|
+
followUp: fact`Use \`capture heap objects ${resultReference(ref)} --constructor <name>\` to inspect one constructor, or \`capture heap diff --before ${resultReference(ref)} --after <snapshot>\` to compare snapshots.`,
|
|
36
|
+
}, { json: parsed.json });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dominators = heap.computeDominators();
|
|
41
|
+
const grouped = new Map<string, { constructor: string; nodeCount: number; retainedBytes: number; selfBytes: number }>();
|
|
42
|
+
for (let index = 0; index < heap.nodeCount; index += 1) {
|
|
43
|
+
const node = heap.nodeAt(index);
|
|
44
|
+
const group = grouped.get(node.name) ?? { constructor: node.name, nodeCount: 0, retainedBytes: 0, selfBytes: 0 };
|
|
45
|
+
group.nodeCount += 1;
|
|
46
|
+
group.retainedBytes += dominators.retainedSizes[index];
|
|
47
|
+
group.selfBytes += node.selfSize;
|
|
48
|
+
grouped.set(node.name, group);
|
|
49
|
+
}
|
|
50
|
+
const groups = [...grouped.values()].sort((a, b) => b.retainedBytes - a.retainedBytes || b.selfBytes - a.selfBytes || a.constructor.localeCompare(b.constructor));
|
|
51
|
+
const displayed = selectRecords(groups, parsed, limit);
|
|
52
|
+
emitResult({
|
|
53
|
+
tag: 'heap-census',
|
|
54
|
+
attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), axis, groups: groups.length, displayed: displayed.length, nodes: heap.nodeCount, 'size-qualification': dominators.sizeQualification },
|
|
55
|
+
summary: text`Constructor totals from the V8 heap snapshot.`,
|
|
56
|
+
artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
|
|
57
|
+
sections: [fact`${dominators.sizeQualification}`, lineList(displayed.map((group, index) => fact`${index + 1}. ${capped(group.constructor, 120)} · nodes=${group.nodeCount} retained-bytes=${group.retainedBytes} self-bytes=${group.selfBytes}`))],
|
|
58
|
+
jsonSections: displayed.map(group => ({ constructor: capped(group.constructor, Number.MAX_SAFE_INTEGER), nodeCount: group.nodeCount, retainedBytes: group.retainedBytes, selfBytes: group.selfBytes, sizeQualification: dominators.sizeQualification })),
|
|
59
|
+
followUp: fact`Use \`capture heap objects ${resultReference(ref)} --constructor <name>\` for Chrome snapshot object ids and \`capture heap retainers ${resultReference(ref)} --node <object-id>\` for a retaining path.`,
|
|
60
|
+
}, { json: parsed.json });
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { captureError } from '../../../errors.js';
|
|
4
|
+
import { HeapSnapshot } from '../../heap-snapshot.js';
|
|
5
|
+
import { assertUnderCaptureRoot, readPrivateFile } from '../../../session/artifacts.js';
|
|
6
|
+
import { getActiveSession } from '../../../session-context.js';
|
|
7
|
+
import type { ResolvedCompletion } from '../../host/collector.js';
|
|
8
|
+
|
|
9
|
+
export interface HeapMeta {
|
|
10
|
+
id: string;
|
|
11
|
+
completion: ResolvedCompletion;
|
|
12
|
+
reason?: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
files: readonly { name: string; bytes: number }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HeapRef {
|
|
18
|
+
readonly kind: 'heap';
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
readonly meta: HeapMeta;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readHeapMeta(dir: string, ref: string): HeapMeta {
|
|
25
|
+
const metaPath = path.join(dir, 'meta.json');
|
|
26
|
+
let raw: Record<string, unknown>;
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(readPrivateFile(metaPath).toString('utf8')) as Record<string, unknown>;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw captureError('artifact', 'heap_ref_unavailable', `Could not read finalized heap snapshot ${JSON.stringify(ref)} at ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
31
|
+
}
|
|
32
|
+
if (raw.kind !== 'heap' || typeof raw.id !== 'string' || !['complete', 'partial', 'orphaned'].includes(String(raw.completion))) {
|
|
33
|
+
throw captureError('artifact', 'heap_ref_unavailable', `Artifact ${JSON.stringify(ref)} is not a finalized heap snapshot.`);
|
|
34
|
+
}
|
|
35
|
+
const files = Array.isArray(raw.files) && raw.files.every(file => file && typeof file === 'object' && typeof (file as { name?: unknown }).name === 'string' && typeof (file as { bytes?: unknown }).bytes === 'number')
|
|
36
|
+
? raw.files as Array<{ name: string; bytes: number }>
|
|
37
|
+
: [];
|
|
38
|
+
const snapshot = files.find(file => file.name === 'snapshot.heapsnapshot');
|
|
39
|
+
let completion = raw.completion as ResolvedCompletion;
|
|
40
|
+
if (!snapshot || !fs.existsSync(path.join(dir, 'snapshot.heapsnapshot')) || fs.statSync(path.join(dir, 'snapshot.heapsnapshot')).size !== snapshot.bytes) completion = 'truncated';
|
|
41
|
+
return { id: raw.id, completion, ...(typeof raw.reason === 'string' ? { reason: raw.reason } : {}), ...(typeof raw.url === 'string' ? { url: raw.url } : {}), files };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveHeapRef(ref: string): HeapRef {
|
|
45
|
+
let dir: string;
|
|
46
|
+
if (path.isAbsolute(ref)) {
|
|
47
|
+
const resolved = assertUnderCaptureRoot(ref);
|
|
48
|
+
dir = path.basename(resolved) === 'snapshot.heapsnapshot' ? path.dirname(resolved) : resolved;
|
|
49
|
+
} else {
|
|
50
|
+
if (ref.includes('/') || ref.includes(path.sep)) throw captureError('invocation', 'heap_ref_invalid', 'Heap snapshot references must be a bare id or an absolute artifact path.');
|
|
51
|
+
const active = getActiveSession();
|
|
52
|
+
if (!active) throw captureError('precondition', 'heap_ref_unavailable', `A bare heap snapshot id (${ref}) requires an active capture session; pass the absolute path printed by capture heap snapshot.`);
|
|
53
|
+
dir = assertUnderCaptureRoot(path.join(active.dir, 'heap', 'snapshots', ref));
|
|
54
|
+
}
|
|
55
|
+
return { kind: 'heap', id: path.basename(dir), dir, meta: readHeapMeta(dir, ref) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadHeap(ref: HeapRef): HeapSnapshot {
|
|
59
|
+
const snapshotPath = path.join(ref.dir, 'snapshot.heapsnapshot');
|
|
60
|
+
if (ref.meta.completion === 'truncated' || !fs.existsSync(snapshotPath)) {
|
|
61
|
+
throw captureError('artifact', 'heap_snapshot_truncated', `Heap snapshot ${ref.id} is ${ref.meta.completion}; snapshot.heapsnapshot does not match its finalized inventory.`);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return HeapSnapshot.parse(readPrivateFile(snapshotPath).toString('utf8'));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw captureError('artifact', 'heap_snapshot_invalid', `Heap snapshot ${ref.id} could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function completionAttrs(meta: HeapMeta): { completion: ResolvedCompletion; 'completion-reason'?: string } {
|
|
71
|
+
return { completion: meta.completion, ...(meta.completion !== 'complete' && meta.reason ? { 'completion-reason': meta.reason } : {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resultReference(ref: HeapRef): string {
|
|
75
|
+
const active = getActiveSession();
|
|
76
|
+
return active && path.join(active.dir, 'heap', 'snapshots', ref.id) === ref.dir ? ref.id : ref.dir;
|
|
77
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { HeapSnapshot } from '../../heap-snapshot.js';
|
|
3
|
+
import { capped, emitResult, fact, formatArtifactList, lineList, text } from '../../../output/render.js';
|
|
4
|
+
import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
|
|
5
|
+
import { selectRecords } from '../../../output/selection.js';
|
|
6
|
+
|
|
7
|
+
const HELP = `capture heap diff --before <snapshot> --after <snapshot> [--limit <N>] — what changed between two heap snapshots
|
|
8
|
+
|
|
9
|
+
input:
|
|
10
|
+
--before <snapshot> required. Heap snapshot id in the active session or an absolute snapshot path
|
|
11
|
+
--after <snapshot> required. Heap snapshot id in the active session or an absolute snapshot path
|
|
12
|
+
--limit <N> return the top N constructors by added retained bytes and top N after-snapshot nodes Chrome marks detached in prose and JSON; default 25
|
|
13
|
+
output: <heap-diff …> — before/after snapshot node and serialized-byte totals, then per constructor nodes added, removed, and grown with retained-byte and detached-node totals; after-snapshot nodes Chrome marks detached are listed with their object ids; --json mirrors
|
|
14
|
+
effects: read-only — reads both finalized snapshot artifacts, never drives the browser`;
|
|
15
|
+
|
|
16
|
+
export function cmdHeapDiff(parsed: ParsedArgs): void {
|
|
17
|
+
if (parsed.help) {
|
|
18
|
+
console.log(HELP);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const before = resolveHeapRef(parsed.before!);
|
|
22
|
+
const after = resolveHeapRef(parsed.after!);
|
|
23
|
+
const beforeHeap = loadHeap(before);
|
|
24
|
+
const afterHeap = loadHeap(after);
|
|
25
|
+
const comparison = HeapSnapshot.compare(beforeHeap, afterHeap);
|
|
26
|
+
const snapshotBytes = (ref: typeof before) => ref.meta.files.find((file) => file.name === 'snapshot.heapsnapshot')?.bytes;
|
|
27
|
+
const totals = {
|
|
28
|
+
before: { nodes: beforeHeap.nodeCount, bytes: snapshotBytes(before) },
|
|
29
|
+
after: { nodes: afterHeap.nodeCount, bytes: snapshotBytes(after) },
|
|
30
|
+
};
|
|
31
|
+
const constructors = [...comparison.constructors].sort((a, b) => b.added.retainedSize - a.added.retainedSize || b.grown.retainedSize - a.grown.retainedSize || b.removed.retainedSize - a.removed.retainedSize || a.constructorName.localeCompare(b.constructorName));
|
|
32
|
+
const limit = parsed.limit ?? 25;
|
|
33
|
+
const displayed = selectRecords(constructors, parsed, limit);
|
|
34
|
+
const detached = selectRecords(comparison.afterDetachedNodes, parsed, limit);
|
|
35
|
+
const detachedSection = detached.length
|
|
36
|
+
? lineList(detached.map((node, index) => fact`${index + 1}. object-id=${node.objectId} constructor=${capped(node.constructorName, 120)} type=${node.type} detachedness=${node.detachedness}`))
|
|
37
|
+
: text`No after-snapshot nodes are marked detached by Chrome.`;
|
|
38
|
+
const jsonSections = [
|
|
39
|
+
{ before: totals.before, after: totals.after },
|
|
40
|
+
...displayed.map(constructor => ({
|
|
41
|
+
constructor: capped(constructor.constructorName, Number.MAX_SAFE_INTEGER),
|
|
42
|
+
added: { nodeCount: constructor.added.nodeCount, detachedNodeCount: constructor.added.detachedNodeCount, retainedSize: constructor.added.retainedSize },
|
|
43
|
+
removed: { nodeCount: constructor.removed.nodeCount, detachedNodeCount: constructor.removed.detachedNodeCount, retainedSize: constructor.removed.retainedSize },
|
|
44
|
+
grown: { nodeCount: constructor.grown.nodeCount, detachedNodeCount: constructor.grown.detachedNodeCount, retainedSize: constructor.grown.retainedSize },
|
|
45
|
+
retainedSizeQualification: comparison.retainedSizeQualification,
|
|
46
|
+
matching: comparison.matching,
|
|
47
|
+
})),
|
|
48
|
+
...(comparison.afterDetachedNodes.length ? [{
|
|
49
|
+
afterDetachedNodes: detached.map(node => ({
|
|
50
|
+
objectId: node.objectId,
|
|
51
|
+
constructor: capped(node.constructorName, Number.MAX_SAFE_INTEGER),
|
|
52
|
+
type: node.type,
|
|
53
|
+
detachedness: node.detachedness,
|
|
54
|
+
})),
|
|
55
|
+
total: comparison.afterDetachedNodes.length,
|
|
56
|
+
displayed: detached.length,
|
|
57
|
+
detachednessQualification: 'Chrome detachedness metadata value 2 marks these after-snapshot nodes detached.',
|
|
58
|
+
}] : []),
|
|
59
|
+
];
|
|
60
|
+
emitResult({
|
|
61
|
+
tag: 'heap-diff',
|
|
62
|
+
attrs: {
|
|
63
|
+
before: before.id,
|
|
64
|
+
'before-path': before.dir,
|
|
65
|
+
...Object.fromEntries(Object.entries(completionAttrs(before.meta)).map(([key, value]) => [`before-${key}`, value])),
|
|
66
|
+
after: after.id,
|
|
67
|
+
'after-path': after.dir,
|
|
68
|
+
...Object.fromEntries(Object.entries(completionAttrs(after.meta)).map(([key, value]) => [`after-${key}`, value])),
|
|
69
|
+
matching: comparison.matching,
|
|
70
|
+
constructors: constructors.length,
|
|
71
|
+
displayed: displayed.length,
|
|
72
|
+
'after-detached-nodes': comparison.afterDetachedNodes.length,
|
|
73
|
+
'detached-displayed': detached.length,
|
|
74
|
+
'retained-size-qualification': comparison.retainedSizeQualification,
|
|
75
|
+
'before-nodes': totals.before.nodes,
|
|
76
|
+
'before-bytes': totals.before.bytes,
|
|
77
|
+
'after-nodes': totals.after.nodes,
|
|
78
|
+
'after-bytes': totals.after.bytes,
|
|
79
|
+
},
|
|
80
|
+
summary: text`Nodes are matched by Chrome snapshot object id. “grown” reports the increase in retained size for id-matched nodes, not their current size.`,
|
|
81
|
+
artifacts: formatArtifactList([{ name: `${resultReference(before)}/snapshot.heapsnapshot`, note: 'before' }, { name: `${resultReference(after)}/snapshot.heapsnapshot`, note: 'after' }]),
|
|
82
|
+
sections: [
|
|
83
|
+
fact`Snapshot totals: before nodes=${totals.before.nodes} serialized-bytes=${totals.before.bytes ?? 'unavailable'}; after nodes=${totals.after.nodes} serialized-bytes=${totals.after.bytes ?? 'unavailable'}.`,
|
|
84
|
+
fact`${comparison.retainedSizeQualification}`,
|
|
85
|
+
lineList(displayed.map((constructor, index) => fact`${index + 1}. ${capped(constructor.constructorName, 120)} · added nodes=${constructor.added.nodeCount} detached-nodes=${constructor.added.detachedNodeCount} retained-bytes=${constructor.added.retainedSize}; removed nodes=${constructor.removed.nodeCount} detached-nodes=${constructor.removed.detachedNodeCount} retained-bytes=${constructor.removed.retainedSize}; grown nodes=${constructor.grown.nodeCount} detached-nodes=${constructor.grown.detachedNodeCount} retained-bytes=${constructor.grown.retainedSize}`)),
|
|
86
|
+
fact`After-snapshot nodes marked detached by Chrome (object-id is the heap retainers --node input):`,
|
|
87
|
+
detachedSection,
|
|
88
|
+
],
|
|
89
|
+
jsonSections,
|
|
90
|
+
followUp: fact`The detached object ids above are inputs to \`capture heap retainers ${resultReference(after)} --node <object-id>\`.`,
|
|
91
|
+
}, { json: parsed.json });
|
|
92
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { invalidInput } from '../../../errors.js';
|
|
2
|
+
import { type ParsedArgs } from '../../types.js';
|
|
3
|
+
import { cmdHeapSnapshot } from './snapshot.js';
|
|
4
|
+
import { cmdHeapCensus } from './census.js';
|
|
5
|
+
import { cmdHeapObjects } from './objects.js';
|
|
6
|
+
import { cmdHeapRetainers } from './retainers.js';
|
|
7
|
+
import { cmdHeapDiff } from './diff.js';
|
|
8
|
+
|
|
9
|
+
export const COMMAND_BLOCK = `<command name="heap">
|
|
10
|
+
the tab's JavaScript heap — \`snapshot\` records the heap as a V8 snapshot, every other leaf is a read-only graph query over it
|
|
11
|
+
use for JavaScript-heap retention: constructor totals, retained sizes, retaining paths, duplicate strings, and before/after comparison
|
|
12
|
+
</command>`;
|
|
13
|
+
|
|
14
|
+
const HELP = `<command name="heap" description="the V8 heap-snapshot substrate">
|
|
15
|
+
<model>\`snapshot\` drives the browser; every other leaf is a read of the finalized snapshot artifact and never touches Chrome. Retained sizes are dominator-subtree sums of Chrome's own self_size. Findings exit 0; input/precondition errors exit 1. No leaf accepts --gate.</model>
|
|
16
|
+
<subcommand name="snapshot" description="take a V8 heap snapshot" whenToUse="Use to write the substrate every other heap leaf reads."/>
|
|
17
|
+
<subcommand name="census" description="what the heap is made of" whenToUse="Use for per-constructor node counts and retained bytes, or for duplicated strings."/>
|
|
18
|
+
<subcommand name="objects" description="the individual objects of one constructor" whenToUse="Use to get the snapshot object ids and sizes you need before asking what retains one."/>
|
|
19
|
+
<subcommand name="retainers" description="what is keeping one object alive" whenToUse="Use with an object id from \`objects\` to get the nearest application-owned retainers."/>
|
|
20
|
+
<subcommand name="diff" description="what changed between two snapshots" whenToUse="Use to see which constructors added, removed, or grew across an interaction."/>
|
|
21
|
+
</command>`;
|
|
22
|
+
|
|
23
|
+
export async function heapMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
24
|
+
const leaf = parsed.positional[0];
|
|
25
|
+
const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
|
|
26
|
+
switch (leaf) {
|
|
27
|
+
case 'snapshot': return cmdHeapSnapshot(rest);
|
|
28
|
+
case 'census': return cmdHeapCensus(rest);
|
|
29
|
+
case 'objects': return cmdHeapObjects(rest);
|
|
30
|
+
case 'retainers': return cmdHeapRetainers(rest);
|
|
31
|
+
case 'diff': return cmdHeapDiff(rest);
|
|
32
|
+
case undefined:
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
return;
|
|
35
|
+
default:
|
|
36
|
+
throw invalidInput(`Unknown heap leaf: ${leaf}.`, 'unknown_command');
|
|
37
|
+
}
|
|
38
|
+
}
|