@crouton-kit/capture 1.3.13 → 1.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +284 -167
- package/package.json +1 -1
- package/src/capture.ts +16 -1
- package/src/cdp/args.ts +4 -3
- package/src/cdp/commands/cdp.ts +13 -5
- package/src/cdp/commands/tab/close.ts +98 -0
- package/src/cdp/commands/tab/index.ts +7 -3
- package/src/cdp/commands/tab/reset.ts +35 -32
- package/src/cdp/connection.ts +12 -3
- package/src/cdp/types.ts +5 -0
- package/src/output/render.ts +1 -0
- package/src/session/commands.ts +5 -20
- package/src/session/coordinator.ts +18 -0
package/package.json
CHANGED
package/src/capture.ts
CHANGED
|
@@ -25,6 +25,18 @@ import { COMMAND_BLOCK as LIB_BLOCK } from './cdp/commands/lib.js';
|
|
|
25
25
|
/** The seven visible root children, in help order. */
|
|
26
26
|
const ROOTS = ['session', 'page', 'tab', 'measure', 'motion', 'cdp', 'lib'] as const;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Guessable former/legacy tokens with one unambiguous current destination —
|
|
30
|
+
* an orientation hint on the `unknown_command` error, never a second
|
|
31
|
+
* dispatchable path (the guessed token still fails; only the message
|
|
32
|
+
* changes). Deliberately small: an entry only earns a place here when its
|
|
33
|
+
* correct destination is unambiguous, so this stays a short discriminator
|
|
34
|
+
* rather than a growing synonym taxonomy.
|
|
35
|
+
*/
|
|
36
|
+
const GUESSABLE_HINTS: Readonly<Record<string, string>> = {
|
|
37
|
+
screenshot: 'page shot',
|
|
38
|
+
};
|
|
39
|
+
|
|
28
40
|
const ROOT_BLOCKS = [
|
|
29
41
|
SESSION_BLOCK,
|
|
30
42
|
PAGE_BLOCK,
|
|
@@ -90,10 +102,13 @@ async function main(): Promise<void> {
|
|
|
90
102
|
return cdpMain();
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
const hint = GUESSABLE_HINTS[command];
|
|
93
106
|
throw captureError(
|
|
94
107
|
'invocation',
|
|
95
108
|
'unknown_command',
|
|
96
|
-
|
|
109
|
+
hint
|
|
110
|
+
? `Unknown command ${command}; did you mean \`capture ${hint}\`? Expected one of the seven roots: session, page, tab, measure, motion, cdp, lib.`
|
|
111
|
+
: `Unknown command ${command}; expected one of the seven roots: session, page, tab, measure, motion, cdp, lib.`,
|
|
97
112
|
);
|
|
98
113
|
}
|
|
99
114
|
|
package/src/cdp/args.ts
CHANGED
|
@@ -60,7 +60,7 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
|
|
|
60
60
|
const branches: Record<string, readonly string[]> = {
|
|
61
61
|
session: ['start', 'stop', 'list', 'view', 'har', 'log'],
|
|
62
62
|
page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
|
|
63
|
-
tab: ['list', 'open', 'reset', 'network'],
|
|
63
|
+
tab: ['list', 'open', 'close', 'reset', 'network'],
|
|
64
64
|
measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
|
|
65
65
|
motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
|
|
66
66
|
lib: ['list', 'search', 'show', 'read'],
|
|
@@ -213,7 +213,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
|
|
|
213
213
|
const next = rest[i + 1];
|
|
214
214
|
if (VALUE_FLAGS.has(arg)) valueFor(arg, next);
|
|
215
215
|
|
|
216
|
-
if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); i++; }
|
|
216
|
+
if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); parsed.portSource = 'flag'; i++; }
|
|
217
217
|
else if (arg === '--out') { parsed.out = valueFor(arg, next); i++; }
|
|
218
218
|
else if (arg === '--json') parsed.json = true;
|
|
219
219
|
else if (arg === '--duration') { parsed.duration = durationMs(valueFor(arg, next)); i++; }
|
|
@@ -313,10 +313,11 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
|
|
|
313
313
|
// A session target is inseparable from the endpoint that created it.
|
|
314
314
|
// Prefer that endpoint over ambient CDP_PORT, but retain an explicit
|
|
315
315
|
// --port override for deliberate multi-browser work.
|
|
316
|
-
if (parsed.port === undefined && session.port !== undefined && session.port !== null) parsed.port = session.port;
|
|
316
|
+
if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
|
|
317
317
|
}
|
|
318
318
|
if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
|
|
319
319
|
parsed.port = integer(process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
|
|
320
|
+
parsed.portSource = 'env';
|
|
320
321
|
}
|
|
321
322
|
// Preserve historical stale-index cleanup once endpoint validation has
|
|
322
323
|
// succeeded; only the malformed-env throw path deliberately leaves it alone.
|
package/src/cdp/commands/cdp.ts
CHANGED
|
@@ -56,7 +56,8 @@ Input:
|
|
|
56
56
|
--params <json> JSON-encoded params object for the method. Spec deviation: params stay inline JSON (no file indirection) — raw CDP params are small protocol-defined objects.
|
|
57
57
|
--wait-event <Domain.event> wait for (and return) the next occurrence of a protocol event; combinable with a method (the method is sent first) or usable alone.
|
|
58
58
|
--browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes — it survives across commands only inside a held session.
|
|
59
|
-
--
|
|
59
|
+
--port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
|
|
60
|
+
--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.
|
|
60
61
|
--timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
|
|
61
62
|
|
|
62
63
|
Output:
|
|
@@ -220,8 +221,10 @@ export async function runBrowserScope(
|
|
|
220
221
|
): Promise<void> {
|
|
221
222
|
const flagTarget = parsed.targetSource === 'flag' ? parsed.target : undefined;
|
|
222
223
|
const session = getActiveSession();
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
const bridgeSocket = session?.bridgeSocket;
|
|
225
|
+
const heldBridgeActive = Boolean(bridgeSocket && fs.existsSync(bridgeSocket));
|
|
226
|
+
if (parsed.portSource !== 'flag' && bridgeSocket && heldBridgeActive) {
|
|
227
|
+
const resp = await deps.sendBridgeRequest(bridgeSocket, {
|
|
225
228
|
method,
|
|
226
229
|
params,
|
|
227
230
|
targetId: flagTarget,
|
|
@@ -247,9 +250,14 @@ export async function runBrowserScope(
|
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
// In-flight diagnostic (stderr): the one-shot connection semantics fact.
|
|
253
|
+
const connectionReason = parsed.portSource === 'flag'
|
|
254
|
+
? heldBridgeActive
|
|
255
|
+
? `Using explicitly selected CDP port ${parsed.port} instead of the active session bridge`
|
|
256
|
+
: `Using explicitly selected CDP port ${parsed.port}`
|
|
257
|
+
: 'No held session bridge active';
|
|
250
258
|
console.error(
|
|
251
|
-
|
|
252
|
-
|
|
259
|
+
`${connectionReason} — this one-shot connection closes when the command exits, ` +
|
|
260
|
+
'so any browser-level grant or target enablement made here reverts immediately after.',
|
|
253
261
|
);
|
|
254
262
|
|
|
255
263
|
const port = parsed.port ?? (await deps.detectCdpPort());
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { captureError, invalidInput } from '../../../errors.js';
|
|
2
|
+
import { detectCdpPort } from '../../detect.js';
|
|
3
|
+
import { closeTarget, findTabById } from '../../targets.js';
|
|
4
|
+
import { getActiveSession } from '../../../session-context.js';
|
|
5
|
+
import { withSessionScopeLifecycle } from '../../../session/coordinator.js';
|
|
6
|
+
import { type CDPTarget, type ParsedArgs } from '../../types.js';
|
|
7
|
+
import {
|
|
8
|
+
data,
|
|
9
|
+
emitResult,
|
|
10
|
+
fact,
|
|
11
|
+
line,
|
|
12
|
+
text,
|
|
13
|
+
type RenderableResult,
|
|
14
|
+
} from '../../../output/render.js';
|
|
15
|
+
|
|
16
|
+
const USAGE = `capture tab close — close one explicitly identified background browser tab.
|
|
17
|
+
|
|
18
|
+
input:
|
|
19
|
+
<target> required — full tab target id or an unambiguous 8-character prefix
|
|
20
|
+
--port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
|
|
21
|
+
|
|
22
|
+
output: <tab-closed port=… target=…> — the closed tab's full target id and last observed URL.
|
|
23
|
+
effects: closes exactly one page tab. Refuses unless any active session's background-tab ownership is known, and never closes that session's tab; reset or stop the session first.`;
|
|
24
|
+
|
|
25
|
+
export function buildTabClosedResult(tab: CDPTarget, port: number): RenderableResult {
|
|
26
|
+
return {
|
|
27
|
+
tag: 'tab-closed',
|
|
28
|
+
attrs: { port, target: tab.id },
|
|
29
|
+
summary: line(text`tab `, data(tab.id.slice(0, 8)), text` closed: `, data(tab.url, 300)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function cmdTabClose(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
34
|
+
if (parsed.help) {
|
|
35
|
+
console.log(USAGE);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const target = parsed.positional[0];
|
|
40
|
+
if (!target) {
|
|
41
|
+
throw invalidInput('received: no target; expected: capture tab close <target> [--port <port>].', 'missing_argument');
|
|
42
|
+
}
|
|
43
|
+
if (target.length < 8) {
|
|
44
|
+
throw invalidInput(`received: target ${target}; expected: a full target id or an unambiguous prefix of at least 8 characters.`, 'target_prefix_too_short');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let port: number;
|
|
48
|
+
try {
|
|
49
|
+
port = parsed.port ?? await detectCdpPort();
|
|
50
|
+
} catch {
|
|
51
|
+
throw captureError(
|
|
52
|
+
'world',
|
|
53
|
+
'no_cdp_endpoint',
|
|
54
|
+
'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>). capture tab list probes every localhost CDP endpoint.',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tab = await withSessionScopeLifecycle(async () => {
|
|
59
|
+
let resolved: CDPTarget | null;
|
|
60
|
+
try {
|
|
61
|
+
resolved = await findTabById(port, target);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw captureError('precondition', 'target_unavailable', `received: target ${target} on port ${port}; expected: one unambiguous page-tab target id. ${error instanceof Error ? error.message : String(error)}`, error);
|
|
64
|
+
}
|
|
65
|
+
if (!resolved || resolved.type !== 'page') {
|
|
66
|
+
throw captureError(
|
|
67
|
+
'precondition',
|
|
68
|
+
'target_unavailable',
|
|
69
|
+
`received: target ${target} on port ${port}; expected: one existing page-tab target id (8-character prefix accepted). capture tab list shows available tabs.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const active = getActiveSession();
|
|
74
|
+
if (active && !active.targetId) {
|
|
75
|
+
throw captureError(
|
|
76
|
+
'precondition',
|
|
77
|
+
'active_session_target_unknown',
|
|
78
|
+
`session ${active.sessionId} has not established its target yet, so capture tab close cannot prove ${resolved.id} is a background tab. Target the session with a page command first, or stop the session with capture session stop ${active.sessionId}.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (active?.targetId === resolved.id) {
|
|
82
|
+
throw captureError(
|
|
83
|
+
'precondition',
|
|
84
|
+
'active_session_target',
|
|
85
|
+
`target ${resolved.id} is the active session ${active.sessionId}'s tab; capture tab close never leaves an active session pointing at a closed target. Use capture tab reset <url> to replace it or capture session stop ${active.sessionId} first.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await closeTarget(port, resolved.id);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw captureError('world', 'close_failed', `closing target ${resolved.id} on port ${port} failed: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
93
|
+
}
|
|
94
|
+
return resolved;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
emitResult(buildTabClosedResult(tab, port), { json: parsed.json });
|
|
98
|
+
}
|
|
@@ -12,14 +12,15 @@ import { invalidInput } from '../../../errors.js';
|
|
|
12
12
|
import { type ParsedArgs } from '../../types.js';
|
|
13
13
|
import { cmdTabList } from './list.js';
|
|
14
14
|
import { cmdTabOpen } from './open.js';
|
|
15
|
+
import { cmdTabClose } from './close.js';
|
|
15
16
|
import { cmdTabReset } from './reset.js';
|
|
16
17
|
import { cmdTabNetwork } from './network.js';
|
|
17
18
|
|
|
18
19
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
19
20
|
export const COMMAND_BLOCK = `<command name="tab">
|
|
20
|
-
tab and connection plumbing — endpoint/tab discovery,
|
|
21
|
-
use when finding a CDP endpoint or tab, opening/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
22
|
-
list · open · reset · network — \`capture tab -h\`
|
|
21
|
+
tab and connection plumbing — endpoint/tab discovery, lifecycle, connection-level network emulation
|
|
22
|
+
use when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
23
|
+
list · open · close · reset · network — \`capture tab -h\`
|
|
23
24
|
</command>`;
|
|
24
25
|
|
|
25
26
|
export const TAB_USAGE = `capture tab — tab and connection plumbing: discovery, lifecycle, network emulation.
|
|
@@ -32,6 +33,7 @@ the session's {target, port} pair together.
|
|
|
32
33
|
|
|
33
34
|
<subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
|
|
34
35
|
<subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
|
|
36
|
+
<subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
|
|
35
37
|
<subcommand name="reset" args="<url> [--port <port>]" whenToUse="abandon a stuck tab and open a fresh one (refuses while a recording is active; reaps dead recorder handles; updates the active session's {target, port} together)"/>
|
|
36
38
|
<subcommand name="network" args="<offline|online>" whenToUse="toggle connection-level network emulation for a tab"/>
|
|
37
39
|
|
|
@@ -46,6 +48,8 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
|
|
|
46
48
|
return cmdTabList(rest, args);
|
|
47
49
|
case 'open':
|
|
48
50
|
return cmdTabOpen(rest, args);
|
|
51
|
+
case 'close':
|
|
52
|
+
return cmdTabClose(rest, args);
|
|
49
53
|
case 'reset':
|
|
50
54
|
return cmdTabReset(rest, args);
|
|
51
55
|
case 'network':
|
|
@@ -22,6 +22,7 @@ import { getActiveSession, updateSessionState } from '../../../session-context.j
|
|
|
22
22
|
import {
|
|
23
23
|
admitSessionOperation,
|
|
24
24
|
withSessionLifecycle,
|
|
25
|
+
withSessionScopeLifecycle,
|
|
25
26
|
scanRecorderHandles,
|
|
26
27
|
clearDanglingRecorderPointer,
|
|
27
28
|
type LifecycleSeams,
|
|
@@ -123,38 +124,40 @@ export async function cmdTabReset(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
123
124
|
const url = parsed.positional[0];
|
|
124
125
|
if (!url) throw invalidInput('received: no URL; expected: capture tab reset <url> [--port <port>].', 'missing_argument');
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await boundedLoadWait(tab);
|
|
136
|
-
emitResult(buildTabResetResult(tab, port, false), { json: parsed.json });
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const op = await admitSessionOperation(session.dir);
|
|
141
|
-
try {
|
|
142
|
-
const result = await withSessionLifecycle(session.dir, async () => {
|
|
143
|
-
const fresh = getActiveSession();
|
|
144
|
-
if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
|
|
145
|
-
const scan = scanRecorderHandles(session.dir, deps.lifecycle);
|
|
146
|
-
if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
|
|
147
|
-
if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
|
|
148
|
-
if (scan.some(h => h.classification === 'live')) 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.');
|
|
149
|
-
await clearDanglingRecorderPointer(session.dir, scan);
|
|
150
|
-
const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
|
|
127
|
+
return withSessionScopeLifecycle(async () => {
|
|
128
|
+
const session = getActiveSession();
|
|
129
|
+
if (!session) {
|
|
130
|
+
let port: number;
|
|
131
|
+
try {
|
|
132
|
+
port = parsed.port ?? (await deps.detectCdpPort());
|
|
133
|
+
} catch {
|
|
134
|
+
throw captureError('world', 'no_cdp_endpoint', 'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>).');
|
|
135
|
+
}
|
|
151
136
|
const tab = await openTabOrThrow(port, url);
|
|
152
137
|
await boundedLoadWait(tab);
|
|
153
|
-
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
138
|
+
emitResult(buildTabResetResult(tab, port, false), { json: parsed.json });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const op = await admitSessionOperation(session.dir);
|
|
143
|
+
try {
|
|
144
|
+
const result = await withSessionLifecycle(session.dir, async () => {
|
|
145
|
+
const fresh = getActiveSession();
|
|
146
|
+
if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
|
|
147
|
+
const scan = scanRecorderHandles(session.dir, deps.lifecycle);
|
|
148
|
+
if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
|
|
149
|
+
if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
|
|
150
|
+
if (scan.some(h => h.classification === 'live')) 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.');
|
|
151
|
+
await clearDanglingRecorderPointer(session.dir, scan);
|
|
152
|
+
const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
|
|
153
|
+
const tab = await openTabOrThrow(port, url);
|
|
154
|
+
await boundedLoadWait(tab);
|
|
155
|
+
await updateSessionState(session.dir, { targetId: tab.id, port });
|
|
156
|
+
return buildTabResetResult(tab, port, true);
|
|
157
|
+
}, deps.lifecycle);
|
|
158
|
+
emitResult(result, { json: parsed.json });
|
|
159
|
+
} finally {
|
|
160
|
+
await op.release();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
160
163
|
}
|
package/src/cdp/connection.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
appendToHarRecording as appendToHar,
|
|
11
11
|
} from '../har-manager.js';
|
|
12
12
|
import { getActiveSession, updateActiveSession, type ActiveSessionState } from '../session-context.js';
|
|
13
|
+
import { withSessionScopeLifecycle } from '../session/coordinator.js';
|
|
13
14
|
import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
|
|
14
15
|
import { recDirFor, readRecorderJson } from './motion/recorder.js';
|
|
15
16
|
import { captureError, invalidInput } from '../errors.js';
|
|
@@ -35,6 +36,7 @@ function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
|
|
|
35
36
|
export interface ConnectionSeams {
|
|
36
37
|
getActiveSession: () => ActiveSessionState | null;
|
|
37
38
|
updateActiveSession: (patch: Partial<ActiveSessionState>) => Promise<ActiveSessionState | null>;
|
|
39
|
+
withSessionScopeLifecycle: <T>(action: () => Promise<T>) => Promise<T>;
|
|
38
40
|
resolveTab: (parsed: ParsedArgs) => Promise<{ port: number; tab: CDPTarget } | null>;
|
|
39
41
|
createClient: (wsUrl: string) => CDPClient;
|
|
40
42
|
appendHar: typeof appendToHar;
|
|
@@ -58,6 +60,7 @@ function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDP
|
|
|
58
60
|
let seams: ConnectionSeams = {
|
|
59
61
|
getActiveSession: () => getActiveSession(),
|
|
60
62
|
updateActiveSession,
|
|
63
|
+
withSessionScopeLifecycle,
|
|
61
64
|
resolveTab: defaultResolveTab,
|
|
62
65
|
createClient: (wsUrl) => new CDPClient(wsUrl),
|
|
63
66
|
appendHar: appendToHar,
|
|
@@ -232,10 +235,16 @@ export async function connectForCommand(
|
|
|
232
235
|
// endpoint precedence already resolved before this leaf ran.
|
|
233
236
|
const port = resolved?.port ?? getPortFromWebSocketDebuggerUrl(tab.webSocketDebuggerUrl);
|
|
234
237
|
|
|
235
|
-
// Lazy target establishment
|
|
236
|
-
//
|
|
238
|
+
// Lazy target establishment shares the scope lifecycle boundary with tab
|
|
239
|
+
// close and session start/stop. Re-read under that boundary so a stale
|
|
240
|
+
// pre-resolution snapshot cannot repoint a replacement session.
|
|
237
241
|
if (activeSession && !activeSession.targetId) {
|
|
238
|
-
await seams.
|
|
242
|
+
await seams.withSessionScopeLifecycle(async () => {
|
|
243
|
+
const current = seams.getActiveSession();
|
|
244
|
+
if (current?.sessionId === activeSession.sessionId && !current.targetId) {
|
|
245
|
+
await seams.updateActiveSession({ targetId: tab.id, port: port ?? null });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
239
248
|
}
|
|
240
249
|
|
|
241
250
|
console.error(
|
package/src/cdp/types.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface ParsedArgs {
|
|
|
10
10
|
command: string;
|
|
11
11
|
positional: string[];
|
|
12
12
|
port?: number;
|
|
13
|
+
/** Where `port` was assigned from: an actual `--port` flag, active-session
|
|
14
|
+
* fallback, or the ambient `CDP_PORT` env var. Browser-scope CDP uses a
|
|
15
|
+
* held session bridge unless this is `'flag'`, which explicitly selects a
|
|
16
|
+
* different browser endpoint. */
|
|
17
|
+
portSource?: 'flag' | 'session' | 'env';
|
|
13
18
|
out?: string;
|
|
14
19
|
json?: boolean;
|
|
15
20
|
duration?: number;
|
package/src/output/render.ts
CHANGED
package/src/session/commands.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
import {
|
|
39
39
|
CAPTURE_ROOT,
|
|
40
40
|
MAX_LOG_LABEL_BYTES,
|
|
41
|
-
acquirePrivateLock,
|
|
42
41
|
ensurePrivateDir,
|
|
43
42
|
rejectLogLabel,
|
|
44
43
|
writeJsonPrivate,
|
|
@@ -47,7 +46,7 @@ import {
|
|
|
47
46
|
type SnapMeta,
|
|
48
47
|
type RecMeta,
|
|
49
48
|
} from './artifacts.js';
|
|
50
|
-
import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
|
|
49
|
+
import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
|
|
51
50
|
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
52
51
|
|
|
53
52
|
type Session = ActiveSessionState;
|
|
@@ -151,22 +150,6 @@ function sessionDir(id: string): string {
|
|
|
151
150
|
return path.join(CAPTURE_ROOT, id);
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
function lifecycleScopeLockPath(): string {
|
|
155
|
-
return path.join(CAPTURE_ROOT, `.session-lifecycle-${process.env.CRTR_NODE_ID ?? 'default'}`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function withLifecycleCoordinator<T>(action: () => Promise<T>): Promise<T> {
|
|
159
|
-
const handle = await acquirePrivateLock(lifecycleScopeLockPath(), {
|
|
160
|
-
acquireTimeoutMs: 120_000,
|
|
161
|
-
leaseMs: 1_000,
|
|
162
|
-
});
|
|
163
|
-
try {
|
|
164
|
-
return await action();
|
|
165
|
-
} finally {
|
|
166
|
-
handle.release();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
153
|
function isSessionStopped(session: Session): boolean {
|
|
171
154
|
return Boolean(session.stoppedAt) || fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
172
155
|
}
|
|
@@ -336,6 +319,8 @@ input:
|
|
|
336
319
|
--hold Hold one CDP browser connection open for the session's
|
|
337
320
|
lifetime, so browser-level state (permission grants,
|
|
338
321
|
ServiceWorker enablement) survives across separate commands.
|
|
322
|
+
With no --url, later --target <existing-id> commands adopt
|
|
323
|
+
an already-open tab without creating a new one.
|
|
339
324
|
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
340
325
|
|
|
341
326
|
Output:
|
|
@@ -567,7 +552,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
567
552
|
|
|
568
553
|
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
|
|
569
554
|
|
|
570
|
-
return
|
|
555
|
+
return withSessionScopeLifecycle(async () => {
|
|
571
556
|
const active = getActiveSession();
|
|
572
557
|
if (active && !isSessionStopped(active)) {
|
|
573
558
|
emitResult({
|
|
@@ -1075,7 +1060,7 @@ async function stop(parsed: ParsedArgs): Promise<void> {
|
|
|
1075
1060
|
return;
|
|
1076
1061
|
}
|
|
1077
1062
|
|
|
1078
|
-
return
|
|
1063
|
+
return withSessionScopeLifecycle(async () => {
|
|
1079
1064
|
let session: Session;
|
|
1080
1065
|
try {
|
|
1081
1066
|
session = readSession(id);
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { captureError } from '../errors.js';
|
|
5
5
|
import {
|
|
6
|
+
CAPTURE_ROOT,
|
|
6
7
|
acquirePrivateLock,
|
|
7
8
|
assertUnderCaptureRoot,
|
|
8
9
|
readPrivateFile,
|
|
@@ -34,6 +35,23 @@ export interface SessionStopAdmission {
|
|
|
34
35
|
finish(success: boolean): Promise<void>;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
function scopeLifecycleLockPath(): string {
|
|
39
|
+
return path.join(CAPTURE_ROOT, `.session-lifecycle-${process.env.CRTR_NODE_ID ?? 'default'}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Serializes active-session publication, teardown, and target ownership changes for one capture scope. */
|
|
43
|
+
export async function withSessionScopeLifecycle<T>(action: () => Promise<T>): Promise<T> {
|
|
44
|
+
const lock = await acquirePrivateLock(scopeLifecycleLockPath(), {
|
|
45
|
+
acquireTimeoutMs: 120_000,
|
|
46
|
+
leaseMs: 1_000,
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
return await action();
|
|
50
|
+
} finally {
|
|
51
|
+
lock.release();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
function statePath(sessionDir: string): string {
|
|
38
56
|
return path.join(assertUnderCaptureRoot(sessionDir), '.operations.json');
|
|
39
57
|
}
|