@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { captureError } from '../../../../errors.js';
|
|
5
|
+
import { CAPTURE_ROOT, unlinkPrivateFile, writePrivateFile } from '../../../../session/artifacts.js';
|
|
6
|
+
import { emitResult, fact, formatArtifactList, text, type RenderableResult } from '../../../../output/render.js';
|
|
7
|
+
import { type ParsedArgs } from '../../../types.js';
|
|
8
|
+
import { parseMockRuleDocument } from '../../../host/collectors/intercept.js';
|
|
9
|
+
import { startMock } from './lifecycle.js';
|
|
10
|
+
|
|
11
|
+
const HELP = `capture tab mock start --rules <path> — intercept the tab's requests and answer them from a rule document
|
|
12
|
+
|
|
13
|
+
input:
|
|
14
|
+
--rules <path> required. Path to a JSON rule document (schema below). Requires an active session with a target; the rules live only as long as the mock, and \`capture tab mock stop\` removes them
|
|
15
|
+
output: <mock …> — the installed mock, its rule count, and the artifact directory holding the copy of exactly what was installed; --json mirrors
|
|
16
|
+
effects: changes what the tab's network does; spawns or joins the session's collector host, which holds the tab connection until the mock is stopped. Claims \`fetch-interception\`: refused while another mock is live, naming its holder. Composes with a live trace and with a live motion recording.
|
|
17
|
+
|
|
18
|
+
rule document:
|
|
19
|
+
{ "rules": [{ "url": "https://api.example.com/users*", "methods": ["GET"], "resourceTypes": ["XHR", "Fetch"], "fulfill": { "status": 200, "headers": { "content-type": "application/json" }, "body": "{\\"users\\":[]}" } }, { "url": "https://cdn.example.com/*.png", "fail": { "reason": "Failed" } }, { "url": "https://api.example.com/health", "passthrough": {} }, { "url": "https://api.example.com/*", "modify": { "headers": { "x-test": "1" } } }] }
|
|
20
|
+
|
|
21
|
+
- An ordered list; first match wins; there is no priority field, because order is the priority.
|
|
22
|
+
- \`url\` is a \`*\`-glob over the full URL — the same syntax CDP's own \`Fetch.enable\` patterns use. No regex in v1.
|
|
23
|
+
- \`methods\` and \`resourceTypes\` are optional narrowing sets. Omitted means "any".
|
|
24
|
+
- Exactly one action per rule: \`fulfill\`, \`fail\`, \`modify\`, or \`passthrough\`. \`passthrough\` exists so a specific request can shadow a later broad rule.
|
|
25
|
+
- There is no default rule and no way to configure one. An unmatched request is continued unmodified.
|
|
26
|
+
- \`fulfill\` bodies are inline (\`body\` string, or \`bodyBase64\`). File-referenced bodies are deferred.
|
|
27
|
+
- Load is all-or-nothing: an unparseable pattern, unknown action, or oversized body refuses the whole document and nothing is installed. \`Fetch.enable\` is never sent for a document that failed validation.`;
|
|
28
|
+
|
|
29
|
+
function stagedRulesPath(): string { return path.join(CAPTURE_ROOT, '.mock-inputs', `rules-${crypto.randomBytes(12).toString('hex')}.json`); }
|
|
30
|
+
|
|
31
|
+
function sourceFor(pathname: string): { source: Buffer; rules: number } {
|
|
32
|
+
let source: Buffer;
|
|
33
|
+
try { source = fs.readFileSync(pathname); }
|
|
34
|
+
catch (error) { throw captureError('artifact', 'mock_rules_unreadable', `could not read mock rules at ${pathname}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
35
|
+
try { return { source, rules: parseMockRuleDocument(source).length }; }
|
|
36
|
+
catch (error) { throw captureError('invocation', 'invalid_mock_rules', `received: ${pathname}\nexpected: a valid tab mock rule document\nfield: --rules\nreason: ${error instanceof Error ? error.message : String(error)}\nNext: Run \`capture tab mock start -h\` and read the rule-document schema before re-issuing.`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildMockStartedResult(mock: { id: string; dir: string; rules: number; targetId: string }): RenderableResult {
|
|
40
|
+
return {
|
|
41
|
+
tag: 'mock',
|
|
42
|
+
attrs: { mock: mock.id, path: mock.dir, state: 'live', rules: mock.rules, target: mock.targetId },
|
|
43
|
+
summary: fact`Installed ${mock.rules} ordered mock rule(s) on the session tab. Requests are answered from the rule document copied to rules.json; first match wins.`,
|
|
44
|
+
artifacts: formatArtifactList([{ name: 'rules.json', note: 'the exact installed rule document' }]),
|
|
45
|
+
followUp: text`Stop and finalize this mock with \`capture tab mock stop\`.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function cmdTabMockStart(parsed: ParsedArgs): Promise<void> {
|
|
50
|
+
if (parsed.help) {
|
|
51
|
+
console.log(HELP);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const rulesPath = parsed.rules;
|
|
55
|
+
if (!rulesPath) throw captureError('invocation', 'missing_rules', 'received: no rule document\nexpected: --rules <path>\nfield: --rules\nNext: Run `capture tab mock start -h` and read the schema before re-issuing.');
|
|
56
|
+
const loaded = sourceFor(rulesPath);
|
|
57
|
+
const staged = stagedRulesPath();
|
|
58
|
+
writePrivateFile(staged, loaded.source);
|
|
59
|
+
try {
|
|
60
|
+
const mock = await startMock(staged, loaded.rules);
|
|
61
|
+
emitResult(buildMockStartedResult(mock), { json: parsed.json });
|
|
62
|
+
} finally {
|
|
63
|
+
try { unlinkPrivateFile(staged); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { emitResult, fact, formatArtifactList, type RenderableResult } from '../../../../output/render.js';
|
|
2
|
+
import { type ParsedArgs } from '../../../types.js';
|
|
3
|
+
import { stopMock, type StoppedMock } from './lifecycle.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `capture tab mock stop — remove the mock rules and finalize the record of what was mocked
|
|
6
|
+
|
|
7
|
+
input:
|
|
8
|
+
(none) the session's one live mock is selected without an id
|
|
9
|
+
output: <mock …> — how many requests paused, how many matched each rule, how many were released unmatched at teardown, and the mock's completion state; --json mirrors
|
|
10
|
+
effects: returns the tab to real network behavior; releases the collector host's \`fetch-interception\` claim and exits the host when nothing else is collecting`;
|
|
11
|
+
|
|
12
|
+
export function buildMockStoppedResult(mock: StoppedMock): RenderableResult {
|
|
13
|
+
const sections = mock.ruleMatches.map((matched, index) => fact`Rule ${index} matched ${matched} request(s).`);
|
|
14
|
+
const incomplete = mock.completion === 'complete' ? [] : [fact`Mock completion is ${mock.completion}; counts reflect the finalized collector record.`];
|
|
15
|
+
return {
|
|
16
|
+
tag: 'mock',
|
|
17
|
+
attrs: {
|
|
18
|
+
mock: mock.id,
|
|
19
|
+
path: mock.dir,
|
|
20
|
+
state: 'stopped',
|
|
21
|
+
completion: mock.completion,
|
|
22
|
+
rules: mock.rules,
|
|
23
|
+
paused: mock.paused,
|
|
24
|
+
matched: mock.matched,
|
|
25
|
+
'released-unmatched': mock.releasedUnmatched,
|
|
26
|
+
},
|
|
27
|
+
summary: fact`Requests were answered from the rule document copied to rules.json; counts are per rule in document order, first match wins. ${mock.paused} request(s) paused, ${mock.matched} matched a rule, and ${mock.releasedUnmatched} paused request(s) were continued unmodified during teardown.`,
|
|
28
|
+
artifacts: formatArtifactList([{ name: 'rules.json', note: 'the exact installed rule document' }, { name: 'interceptions.jsonl', note: 'one record per paused request' }, { name: 'meta.json', note: 'completion and collector counts' }]),
|
|
29
|
+
sections: [...sections, ...incomplete],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function cmdTabMockStop(parsed: ParsedArgs): Promise<void> {
|
|
34
|
+
if (parsed.help) {
|
|
35
|
+
console.log(HELP);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
emitResult(buildMockStoppedResult(await stopMock()), { json: parsed.json });
|
|
39
|
+
}
|
|
@@ -15,6 +15,8 @@ import { captureError } from '../../../errors.js';
|
|
|
15
15
|
import { getActiveSession, setActiveNetworkOffline } from '../../../session-context.js';
|
|
16
16
|
import { admitSessionOperation } from '../../../session/coordinator.js';
|
|
17
17
|
import { sendBridgeRequest } from '../../bridge/client.js';
|
|
18
|
+
import { sendHostRequest } from '../../host/client.js';
|
|
19
|
+
import { scanCollectorHost } from '../../host/handle.js';
|
|
18
20
|
import { type ParsedArgs } from '../../types.js';
|
|
19
21
|
import {
|
|
20
22
|
emitResult,
|
|
@@ -133,16 +135,13 @@ export async function cmdTabNetwork(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
133
135
|
`Network.enable failed over the session's held bridge: ${enable.error || 'unknown bridge error'}`,
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
},
|
|
144
|
-
targetId,
|
|
145
|
-
});
|
|
138
|
+
const params = {
|
|
139
|
+
offline,
|
|
140
|
+
latency: offline ? -1 : 0,
|
|
141
|
+
downloadThroughput: offline ? 0 : -1,
|
|
142
|
+
uploadThroughput: offline ? 0 : -1,
|
|
143
|
+
};
|
|
144
|
+
const emulate = await sendBridgeRequest(socket, { method: 'Network.emulateNetworkConditions', params, targetId });
|
|
146
145
|
if (!emulate.ok) {
|
|
147
146
|
throw captureError(
|
|
148
147
|
'world',
|
|
@@ -150,9 +149,19 @@ export async function cmdTabNetwork(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
150
149
|
`Network.emulateNetworkConditions failed over the session's held bridge: ${emulate.error || 'unknown bridge error'}`,
|
|
151
150
|
);
|
|
152
151
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
const host = scanCollectorHost(session.dir);
|
|
153
|
+
if (host.classification === 'live' && host.handle) {
|
|
154
|
+
if (host.handle.targetId !== targetId) throw captureError('precondition', 'collector_host_target_mismatch', 'collector host is bound to a different session target; stop the session before changing tabs.');
|
|
155
|
+
const hostEnable = await sendHostRequest(host.handle.socketPath, { type: 'cdp', nonce: host.handle.nonce, method: 'Network.enable' });
|
|
156
|
+
if (!hostEnable.ok) throw captureError('world', 'collector_host_error', `Network.enable failed over the collector host: ${hostEnable.error || 'unknown bridge error'}`);
|
|
157
|
+
const hostEmulate = await sendHostRequest(host.handle.socketPath, { type: 'cdp', nonce: host.handle.nonce, method: 'Network.emulateNetworkConditions', params });
|
|
158
|
+
if (!hostEmulate.ok) throw captureError('world', 'collector_host_error', `Network.emulateNetworkConditions failed over the collector host: ${hostEmulate.error || 'unknown bridge error'}`);
|
|
159
|
+
} else if (host.classification !== 'absent') {
|
|
160
|
+
throw captureError('precondition', 'collector_host_unavailable', `session collector host is ${host.classification}.`);
|
|
161
|
+
}
|
|
162
|
+
// Local-main session network retention: record the emulation on the active
|
|
163
|
+
// session only after every live connection carrying the session condition
|
|
164
|
+
// has accepted it.
|
|
156
165
|
await setActiveNetworkOffline(offline);
|
|
157
166
|
} finally {
|
|
158
167
|
await operation.release();
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Under an active session this is a session-lifecycle transition: it admits
|
|
7
7
|
* as a session operation, runs under the session's `.lifecycle.lock`, and
|
|
8
|
-
* REFUSES while a recording is live (
|
|
9
|
-
* reset never rebinds
|
|
10
|
-
* pointer; dead
|
|
11
|
-
*
|
|
8
|
+
* REFUSES while a recording is live (the collector host is bound to the old
|
|
9
|
+
* target; reset never rebinds it). Reset only clears the session's dangling
|
|
10
|
+
* recording pointer; a dead collector host is left for the next stop/start to
|
|
11
|
+
* finalize its artifacts. The session's `{targetId, port}` pair is published
|
|
12
12
|
* atomically under the lock.
|
|
13
13
|
*
|
|
14
14
|
* Emits a `<tab-reset>` block; page-derived strings flow through `data()`
|
|
@@ -23,11 +23,10 @@ import {
|
|
|
23
23
|
admitSessionOperation,
|
|
24
24
|
withSessionLifecycle,
|
|
25
25
|
withSessionScopeLifecycle,
|
|
26
|
-
scanRecorderHandles,
|
|
27
|
-
clearDanglingRecorderPointer,
|
|
28
26
|
type LifecycleSeams,
|
|
29
27
|
} from '../../../session/coordinator.js';
|
|
30
28
|
import { captureError, invalidInput } from '../../../errors.js';
|
|
29
|
+
import { scanCollectorHost } from '../../host/handle.js';
|
|
31
30
|
import { type CDPTarget, type ParsedArgs } from '../../types.js';
|
|
32
31
|
import {
|
|
33
32
|
data,
|
|
@@ -44,7 +43,7 @@ input:
|
|
|
44
43
|
--port <port> CDP endpoint (default: the active session's port, else the auto-discovered preferred endpoint)
|
|
45
44
|
|
|
46
45
|
output: <tab-reset port=… target=…> — the fresh tab's target id and url, plus whether the active session's target was repointed at it.
|
|
47
|
-
effects: opens a new background browser tab (the old tab is left behind, not closed) and waits up to 10s for its load event. Under an active session it refuses while a recording is live (stop it first: \`capture motion rec --stop\`), clears
|
|
46
|
+
effects: opens a new background browser tab (the old tab is left behind, not closed) and waits up to 10s for its load event. Under an active session it refuses while a recording is live (stop it first: \`capture motion rec --stop\`), clears the session's dangling recording pointer (a dead collector host remains for the next stop/start to finalize its artifacts), and updates the session's {target, port} pair together.`;
|
|
48
47
|
|
|
49
48
|
/** Pure `<tab-reset>` result builder — exported for tests. */
|
|
50
49
|
export function buildTabResetResult(
|
|
@@ -144,11 +143,11 @@ export async function cmdTabReset(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
144
143
|
const result = await withSessionLifecycle(session.dir, async () => {
|
|
145
144
|
const fresh = getActiveSession();
|
|
146
145
|
if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
if (
|
|
150
|
-
if (
|
|
151
|
-
await
|
|
146
|
+
const host = scanCollectorHost(session.dir);
|
|
147
|
+
if (host.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine collector-host liveness; refusing to reset.');
|
|
148
|
+
if (host.classification === 'malformed') throw captureError('precondition', 'recorder_unavailable', 'A malformed collector-host handle exists on this session; resolve it before resetting.');
|
|
149
|
+
if (host.classification === 'live' && host.handle?.collectors.some(collector => collector.kind === 'motion')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
|
|
150
|
+
if (fresh.activeRecId) await updateSessionState(session.dir, { activeRecId: null });
|
|
152
151
|
const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
|
|
153
152
|
const tab = await openTabOrThrow(port, url);
|
|
154
153
|
await boundedLoadWait(tab);
|
package/src/cdp/connection.ts
CHANGED
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
} from '../har-manager.js';
|
|
13
13
|
import { getActiveSession, updateActiveSession, type ActiveSessionState } from '../session-context.js';
|
|
14
14
|
import { withSessionScopeLifecycle } from '../session/coordinator.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { MotionHeldClient, isMotionHeldClient } from './host/held-client.js';
|
|
16
|
+
import { scanCollectorHost } from './host/handle.js';
|
|
17
17
|
import { captureError } from '../errors.js';
|
|
18
18
|
|
|
19
19
|
function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
|
|
@@ -41,12 +41,12 @@ export interface ConnectionSeams {
|
|
|
41
41
|
resolveTab: (parsed: ParsedArgs) => Promise<{ port: number; tab: CDPTarget } | null>;
|
|
42
42
|
createClient: (wsUrl: string) => CDPClient;
|
|
43
43
|
appendHar: typeof appendToHar;
|
|
44
|
-
/**
|
|
45
|
-
* barrier (`
|
|
44
|
+
/** Motion-routed actions only: the collector host's `har-flush` health
|
|
45
|
+
* barrier (`MotionHeldClient.flushHar()`) — resolves once every HAR
|
|
46
46
|
* entry/body/append the recorder's streaming collector had completed at
|
|
47
47
|
* call time is durably in the owning session's live HAR; throws on a
|
|
48
48
|
* latched fatal store failure. Never invoked for a non-recorder action. */
|
|
49
|
-
flushRecorderHar: (client:
|
|
49
|
+
flushRecorderHar: (client: MotionHeldClient) => Promise<void>;
|
|
50
50
|
/** Monotonic clock (ms) — measured settle facts derive from it, never from wall time. */
|
|
51
51
|
now: () => number;
|
|
52
52
|
sleep: (ms: number) => Promise<void>;
|
|
@@ -150,51 +150,31 @@ function connectToActiveRecorder(
|
|
|
150
150
|
parsed: ParsedArgs,
|
|
151
151
|
): { client: CDPClient; tab: CDPTarget } {
|
|
152
152
|
const recId = session.activeRecId!;
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
if (!rj) {
|
|
157
|
-
throw captureError(
|
|
158
|
-
'precondition',
|
|
159
|
-
'recorder_unavailable',
|
|
160
|
-
`The active session claims recording "${recId}" but its live-recorder handle (recorder.json) is missing — it was already finalized or reaped. Recover with: capture motion rec --stop.`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
if (
|
|
164
|
-
typeof rj.socketPath !== 'string' || rj.socketPath.length === 0
|
|
165
|
-
|| typeof rj.targetId !== 'string' || rj.targetId.length === 0
|
|
166
|
-
|| typeof rj.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(rj.nonce)
|
|
167
|
-
) {
|
|
168
|
-
throw captureError(
|
|
169
|
-
'precondition',
|
|
170
|
-
'recorder_unavailable',
|
|
171
|
-
`The active session's recorder handle for "${recId}" is malformed (missing or invalid socketPath/targetId/nonce). Recover with: capture motion rec --stop.`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
if (rj.state !== 'recording') {
|
|
153
|
+
const scanned = scanCollectorHost(session.dir);
|
|
154
|
+
const collector = scanned.handle?.collectors.find(value => value.id === recId && value.kind === 'motion');
|
|
155
|
+
if (scanned.classification !== 'live' || !scanned.handle || !collector) {
|
|
175
156
|
throw captureError(
|
|
176
157
|
'precondition',
|
|
177
158
|
'recorder_unavailable',
|
|
178
|
-
`The active session
|
|
159
|
+
`The active session claims recording "${recId}" but its live collector-host handle is unavailable. Recover with: capture motion rec --stop.`,
|
|
179
160
|
);
|
|
180
161
|
}
|
|
181
162
|
|
|
182
163
|
const actionLabel = deriveActionLabel(parsed);
|
|
183
|
-
const client = new
|
|
164
|
+
const client = new MotionHeldClient({ socketPath: scanned.handle.socketPath, nonce: scanned.handle.nonce, actionLabel });
|
|
184
165
|
const tab: CDPTarget = {
|
|
185
|
-
id:
|
|
166
|
+
id: scanned.handle.targetId,
|
|
186
167
|
title: '',
|
|
187
|
-
url:
|
|
168
|
+
url: session.url ?? '',
|
|
188
169
|
type: 'page',
|
|
189
170
|
webSocketDebuggerUrl: undefined,
|
|
190
171
|
};
|
|
191
172
|
|
|
192
|
-
console.error(`Routing via active
|
|
173
|
+
console.error(`Routing via active motion collector ${recId} (collector-host tab connection, action "${actionLabel}")`);
|
|
193
174
|
|
|
194
|
-
// Documented cast —
|
|
175
|
+
// Documented cast — MotionHeldClient only ever needs to satisfy the
|
|
195
176
|
// structural send/on/onDisconnect/close surface every command leaf calls;
|
|
196
|
-
// see
|
|
197
|
-
// recorder-bridge.ts's `asCDPClient()`.
|
|
177
|
+
// see held-client.ts and host/collectors/motion.ts's `asCDPClient()`.
|
|
198
178
|
return { client: client as unknown as CDPClient, tab };
|
|
199
179
|
}
|
|
200
180
|
|
|
@@ -204,8 +184,8 @@ export async function connectForCommand(
|
|
|
204
184
|
const activeSession = seams.getActiveSession();
|
|
205
185
|
if (activeSession && shouldRouteToRecorder(activeSession, parsed)) {
|
|
206
186
|
const routed = connectToActiveRecorder(activeSession, parsed);
|
|
207
|
-
// The
|
|
208
|
-
//
|
|
187
|
+
// The collector host owns its own persistent target connection, so the
|
|
188
|
+
// persisted offline/online state must be reissued here too — the
|
|
209
189
|
// ephemeral connection that ran `network offline` has since closed.
|
|
210
190
|
await applyActiveSessionNetworkConditions(routed.client, activeSession, routed.tab.id);
|
|
211
191
|
return routed;
|
|
@@ -285,7 +265,7 @@ interface Collectors {
|
|
|
285
265
|
}
|
|
286
266
|
|
|
287
267
|
async function startCollectors(parsed: ParsedArgs, client: CDPClient): Promise<Collectors> {
|
|
288
|
-
const routed =
|
|
268
|
+
const routed = isMotionHeldClient(client);
|
|
289
269
|
|
|
290
270
|
let consoleRecorder: ConsoleRecorder | undefined;
|
|
291
271
|
if (!routed) {
|
|
@@ -433,7 +413,7 @@ export async function withPageAction<T>(
|
|
|
433
413
|
// this request.
|
|
434
414
|
if (collectors.routed) {
|
|
435
415
|
try {
|
|
436
|
-
await seams.flushRecorderHar(client as unknown as
|
|
416
|
+
await seams.flushRecorderHar(client as unknown as MotionHeldClient);
|
|
437
417
|
} catch (err) {
|
|
438
418
|
throw captureError(
|
|
439
419
|
'artifact',
|
package/src/cdp/detect.ts
CHANGED
|
@@ -226,22 +226,30 @@ export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
|
|
|
226
226
|
// browser out of everything discovered on localhost. Shared so `capture
|
|
227
227
|
// detect`'s printed default always matches what auto-discovery actually
|
|
228
228
|
// picks for session start / navigate / etc.
|
|
229
|
-
export
|
|
229
|
+
export interface PreferredEndpointSelection {
|
|
230
|
+
endpoint: CdpEndpoint;
|
|
231
|
+
reason: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function pickPreferredEndpointWithReason(
|
|
230
235
|
endpoints: CdpEndpoint[],
|
|
231
236
|
defaultBrowser: string | null,
|
|
232
|
-
):
|
|
237
|
+
): PreferredEndpointSelection {
|
|
233
238
|
// Endpoints that already host a real page/tab are real browsers. CDP-
|
|
234
239
|
// speaking non-browser processes (Node/workerd inspectors, etc.) never do,
|
|
235
240
|
// so prefer real browsers whenever at least one is present — this is what
|
|
236
241
|
// keeps an unrelated CDP listener from being picked over the intended one.
|
|
237
242
|
const withPages = endpoints.filter((e) => e.hasPageTarget);
|
|
238
243
|
const candidates = withPages.length > 0 ? withPages : endpoints;
|
|
244
|
+
const candidateScope = withPages.length > 0
|
|
245
|
+
? 'among endpoints with a live page target'
|
|
246
|
+
: 'because no discovered endpoint has a live page target';
|
|
239
247
|
|
|
240
248
|
if (defaultBrowser) {
|
|
241
249
|
const match = candidates.find(
|
|
242
250
|
(p) => p.bundleId === defaultBrowser && !p.isElectron,
|
|
243
251
|
);
|
|
244
|
-
if (match) return match;
|
|
252
|
+
if (match) return { endpoint: match, reason: `matched the configured default browser ${candidateScope}` };
|
|
245
253
|
}
|
|
246
254
|
|
|
247
255
|
// Prefer a recognized browser over other Chromium hosts (Spotify, app
|
|
@@ -249,12 +257,20 @@ export function pickPreferredEndpoint(
|
|
|
249
257
|
const knownBrowser = candidates.find(
|
|
250
258
|
(endpoint) => endpoint.bundleId !== 'unknown' && !endpoint.isElectron,
|
|
251
259
|
);
|
|
252
|
-
if (knownBrowser) return knownBrowser;
|
|
260
|
+
if (knownBrowser) return { endpoint: knownBrowser, reason: `is a recognized non-Electron browser ${candidateScope}` };
|
|
253
261
|
const nonElectron = candidates.find((endpoint) => !endpoint.isElectron);
|
|
254
|
-
return nonElectron
|
|
262
|
+
if (nonElectron) return { endpoint: nonElectron, reason: `is the first non-Electron endpoint ${candidateScope}` };
|
|
263
|
+
return { endpoint: candidates[0], reason: `is the first discovered endpoint ${candidateScope}` };
|
|
255
264
|
}
|
|
256
265
|
|
|
257
|
-
export
|
|
266
|
+
export function pickPreferredEndpoint(
|
|
267
|
+
endpoints: CdpEndpoint[],
|
|
268
|
+
defaultBrowser: string | null,
|
|
269
|
+
): CdpEndpoint {
|
|
270
|
+
return pickPreferredEndpointWithReason(endpoints, defaultBrowser).endpoint;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function detectCdpEndpoint(): Promise<CdpEndpoint & { selectionReason: string }> {
|
|
258
274
|
const endpoints = await detectCdpPortsAsync();
|
|
259
275
|
if (endpoints.length === 0) {
|
|
260
276
|
throw new Error(
|
|
@@ -264,6 +280,10 @@ export async function detectCdpPort(): Promise<number> {
|
|
|
264
280
|
' Electron apps expose CDP automatically',
|
|
265
281
|
);
|
|
266
282
|
}
|
|
283
|
+
const { endpoint, reason } = pickPreferredEndpointWithReason(endpoints, getDefaultBrowserId());
|
|
284
|
+
return { ...endpoint, selectionReason: reason };
|
|
285
|
+
}
|
|
267
286
|
|
|
268
|
-
|
|
287
|
+
export async function detectCdpPort(): Promise<number> {
|
|
288
|
+
return (await detectCdpEndpoint()).port;
|
|
269
289
|
}
|
package/src/cdp/dispatch.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared dispatch for every routed command. Two jobs live here:
|
|
3
3
|
*
|
|
4
4
|
* 1. Route each root (`session`, `page`, `tab`, `measure`, `motion`,
|
|
5
|
-
* `cdp`, `lib`, hidden `__bridge-serve`) to its branch main.
|
|
5
|
+
* `perf`, `heap`, `lighthouse`, `cdp`, `lib`, hidden `__bridge-serve`) to its branch main.
|
|
6
6
|
* 2. Invoke the `--gate` guard ONCE for the whole surface: every command
|
|
7
7
|
* that is not `measure check|diff` rejects the flag structurally here,
|
|
8
8
|
* before any branch main runs.
|
|
@@ -20,6 +20,10 @@ import { cmdCdp } from './commands/cdp.js';
|
|
|
20
20
|
import { cmdBridgeServe } from './commands/bridge-serve.js';
|
|
21
21
|
import { measureMain } from './commands/measure/index.js';
|
|
22
22
|
import { motionMain } from './commands/motion/index.js';
|
|
23
|
+
import { perfMain } from './commands/perf/index.js';
|
|
24
|
+
import { heapMain } from './commands/heap/index.js';
|
|
25
|
+
import { cmdLighthouse } from './commands/lighthouse.js';
|
|
26
|
+
import { cmdSessionCollectors } from './commands/session/collectors.js';
|
|
23
27
|
|
|
24
28
|
/** The dotted leaf name the gate rejection reports, derived from the parsed
|
|
25
29
|
* command plus its first positional (the branch-leaf token). */
|
|
@@ -50,10 +54,20 @@ async function withActiveSessionAdmission(parsed: ParsedArgs, run: () => Promise
|
|
|
50
54
|
const active = getActiveSession();
|
|
51
55
|
if (!active) return run();
|
|
52
56
|
const operation = await admitSessionOperation(active.dir);
|
|
57
|
+
let completed = false;
|
|
53
58
|
try {
|
|
54
|
-
|
|
59
|
+
await run();
|
|
60
|
+
completed = true;
|
|
55
61
|
} finally {
|
|
56
|
-
|
|
62
|
+
try {
|
|
63
|
+
await operation.release();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!completed) throw error;
|
|
66
|
+
// The command's browser effect and rendered measurement already
|
|
67
|
+
// completed. A failed auxiliary lock-owner lookup cannot recast that
|
|
68
|
+
// completed observation as a failed navigation/page command.
|
|
69
|
+
process.stderr.write(`capture: session-operation release unavailable after completed command: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
70
|
+
}
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
73
|
|
|
@@ -70,13 +84,20 @@ export async function cdpMain(): Promise<void> {
|
|
|
70
84
|
const resolved = resolveCliContext(parsed);
|
|
71
85
|
|
|
72
86
|
switch (resolved.command) {
|
|
73
|
-
case 'session':
|
|
87
|
+
case 'session':
|
|
88
|
+
if (resolved.positional[0] === 'collectors') {
|
|
89
|
+
return cmdSessionCollectors({ ...resolved, positional: resolved.positional.slice(1) });
|
|
90
|
+
}
|
|
91
|
+
return sessionMain(resolved, args);
|
|
74
92
|
case 'page': return withActiveSessionAdmission(resolved, () => pageMain(resolved, args));
|
|
75
93
|
case 'tab': return tabMain(resolved, args);
|
|
76
94
|
case 'lib': return cmdLib(resolved, args);
|
|
77
95
|
case 'cdp': return cmdCdp(resolved, args);
|
|
78
96
|
case 'measure': return withActiveSessionAdmission(resolved, () => measureMain(resolved, args));
|
|
79
97
|
case 'motion': return withActiveSessionAdmission(resolved, () => motionMain(resolved, args));
|
|
98
|
+
case 'perf': return withActiveSessionAdmission(resolved, () => perfMain(resolved, args));
|
|
99
|
+
case 'heap': return withActiveSessionAdmission(resolved, () => heapMain(resolved, args));
|
|
100
|
+
case 'lighthouse': return withActiveSessionAdmission(resolved, () => Promise.resolve(cmdLighthouse(resolved, args)));
|
|
80
101
|
case '__bridge-serve': return cmdBridgeServe(resolved, args);
|
|
81
102
|
default:
|
|
82
103
|
// capture.ts routes only the known roots into cdpMain(); an unknown
|
package/src/cdp/domains.ts
CHANGED
|
@@ -29,11 +29,11 @@ export async function enableDomainsForSnap(client: CDPClient): Promise<void> {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Enables the CDP domains the motion
|
|
32
|
+
* Enables the CDP domains the motion collector needs: frame/navigation metadata and
|
|
33
33
|
* `Page.startScreencast` (Page), element geometry for rect/selector resolution (DOM), script
|
|
34
34
|
* execution for the injected Mutation/Resize/Performance observers (Runtime), request/response
|
|
35
35
|
* timing for `motion response` (Network), and playback-rate control for the animation inventory
|
|
36
|
-
* (Animation). `Tracing.start` is configured with its own category set by the
|
|
36
|
+
* (Animation). `Tracing.start` is configured with its own category set by the motion collector
|
|
37
37
|
* (U13) and is not a plain `.enable` call, so it is not included here.
|
|
38
38
|
*/
|
|
39
39
|
export async function enableDomainsForMotionRec(client: CDPClient): Promise<void> {
|
package/src/cdp/har-recorder.ts
CHANGED
|
@@ -514,7 +514,6 @@ export class HARRecorder {
|
|
|
514
514
|
const terminal = current.terminal!;
|
|
515
515
|
if (responseTimestamp !== null && responseTimestamp < current.requestMonotonic) return 'response_before_request';
|
|
516
516
|
if (terminal.timestamp < current.requestMonotonic) return 'terminal_before_request';
|
|
517
|
-
if (responseTimestamp !== null && terminal.timestamp < responseTimestamp) return 'terminal_before_response';
|
|
518
517
|
return null;
|
|
519
518
|
}
|
|
520
519
|
|