@crouton-kit/capture 1.3.7 → 1.3.9
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 +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1894 -0
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +302 -34
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -77
- package/src/tab-session.ts +0 -91
package/src/session-context.ts
CHANGED
|
@@ -1,62 +1,329 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session Context — persists active session state so CDP commands
|
|
3
|
-
*
|
|
2
|
+
* Session Context — persists active session state so CDP commands can target
|
|
3
|
+
* the session tab and write session-owned artifacts without manual threading.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A scoped, private active-index `.active-<scope>` only tracks the active
|
|
6
|
+
* session identity and directory; mutable/live state lives in the canonical
|
|
7
|
+
* per-session `.session.json` file under that directory.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
|
-
import
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
CAPTURE_ROOT,
|
|
14
|
+
acquirePrivateLock,
|
|
15
|
+
assertUnderCaptureRoot,
|
|
16
|
+
ensurePrivateDir,
|
|
17
|
+
parseRegisteredLogTailer,
|
|
18
|
+
readPrivateFile,
|
|
19
|
+
removeArtifactTree,
|
|
20
|
+
unlinkPrivateFile,
|
|
21
|
+
writeJsonPrivate,
|
|
22
|
+
type RecMeta,
|
|
23
|
+
type RegisteredLogTailer,
|
|
24
|
+
type SnapMeta,
|
|
25
|
+
} from './session/artifacts.js';
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
function activeScopeKey(): string {
|
|
28
|
+
return process.env.CRTR_NODE_ID ?? 'default';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function activeSessionScopeKey(): string {
|
|
32
|
+
return activeScopeKey();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getActivePath(): string {
|
|
36
|
+
return path.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
|
|
37
|
+
}
|
|
15
38
|
|
|
39
|
+
interface ActiveSessionIndex {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
dir: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The sole authoritative mutable record stored in `<dir>/.session.json`. */
|
|
16
45
|
export interface ActiveSessionState {
|
|
17
46
|
sessionId: string;
|
|
18
47
|
dir: string;
|
|
19
48
|
harId: string | null;
|
|
49
|
+
startedAt?: string;
|
|
50
|
+
url?: string | null;
|
|
20
51
|
targetId: string | null;
|
|
21
52
|
stepCount: number;
|
|
53
|
+
/** Identity-bearing registered log tailer handles owned by this session. */
|
|
54
|
+
logPids?: RegisteredLogTailer[];
|
|
55
|
+
/**
|
|
56
|
+
* Optional CDP endpoint socket port used by this session.
|
|
57
|
+
* Session index files never keep this; it is only in `.session.json`.
|
|
58
|
+
*/
|
|
59
|
+
port?: number | null;
|
|
22
60
|
/** Unix socket of the session's held CDP bridge (`session start --hold`), if any. */
|
|
23
61
|
bridgeSocket?: string | null;
|
|
62
|
+
/** Network.emulateNetworkConditions offline state for the session target. */
|
|
63
|
+
networkOffline?: boolean;
|
|
64
|
+
/** Child process id of the held CDP bridge, if any. */
|
|
65
|
+
bridgePid?: number | null;
|
|
66
|
+
/**
|
|
67
|
+
* The id of the recording (`motion/recs/{recId}`) currently live under this
|
|
68
|
+
* session, if `motion rec --start` has armed one.
|
|
69
|
+
*/
|
|
70
|
+
activeRecId?: string | null;
|
|
71
|
+
/** Marker written after successful stop to keep stale active indices from blocking.
|
|
72
|
+
* It is not part of the active index; only read from `.session.json`.
|
|
73
|
+
*/
|
|
74
|
+
stoppedAt?: string | null;
|
|
75
|
+
/** Optional per-session run-state marker used by later lifecycle units. */
|
|
76
|
+
stopping?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isIndex(value: unknown): value is ActiveSessionIndex {
|
|
80
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
81
|
+
const record = value as Record<string, unknown>;
|
|
82
|
+
return typeof record.sessionId === 'string' && typeof record.dir === 'string';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Directory-entry identity of the `.active-<scope>` pointer, for compare-clear. */
|
|
86
|
+
interface ActiveEntryIdentity {
|
|
87
|
+
dev: string;
|
|
88
|
+
ino: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function statActiveEntry(): ActiveEntryIdentity | null {
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.lstatSync(getActivePath(), { bigint: true });
|
|
94
|
+
return { dev: stat.dev.toString(), ino: stat.ino.toString() };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ActivePointer {
|
|
102
|
+
index: ActiveSessionIndex | null;
|
|
103
|
+
/** Identity of the exact entry this read observed; null when no entry existed. */
|
|
104
|
+
entry: ActiveEntryIdentity | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads the active pointer together with the identity of the directory entry
|
|
109
|
+
* it came from. The identity is captured BEFORE the content read, so a stale
|
|
110
|
+
* determination made from these bytes can only ever authorize clearing this
|
|
111
|
+
* exact inode — never a pointer published after it.
|
|
112
|
+
*/
|
|
113
|
+
function readActivePointer(): ActivePointer {
|
|
114
|
+
const entry = statActiveEntry();
|
|
115
|
+
if (!entry) return { index: null, entry: null };
|
|
116
|
+
try {
|
|
117
|
+
const raw = readPrivateFile(getActivePath()).toString('utf-8');
|
|
118
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
119
|
+
if (!isIndex(parsed)) return { index: null, entry };
|
|
120
|
+
return { index: { sessionId: parsed.sessionId, dir: parsed.dir }, entry };
|
|
121
|
+
} catch {
|
|
122
|
+
return { index: null, entry };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sessionMetaPath(sessionDir: string): string {
|
|
127
|
+
return path.join(sessionDir, '.session.json');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sessionStateLockPath(sessionDir: string): string {
|
|
131
|
+
return path.join(assertUnderCaptureRoot(sessionDir), '.session-state.lock');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function withSessionStateLock<T>(sessionDir: string, action: () => T): Promise<T> {
|
|
135
|
+
const lock = await acquirePrivateLock(sessionStateLockPath(sessionDir), {
|
|
136
|
+
acquireTimeoutMs: 30_000,
|
|
137
|
+
leaseMs: 500,
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
return action();
|
|
141
|
+
} finally {
|
|
142
|
+
lock.release();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The one schema check for a persisted `.session.json` record. Exported so
|
|
147
|
+
* every session-record reader (here and `session/commands.ts`) validates the
|
|
148
|
+
* same shape instead of trusting parsed JSON structurally. */
|
|
149
|
+
export function isActiveStateCandidate(value: unknown): value is ActiveSessionState {
|
|
150
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
151
|
+
const record = value as Record<string, unknown>;
|
|
152
|
+
const nullableString = (field: unknown): boolean => field === undefined || field === null || typeof field === 'string';
|
|
153
|
+
const nullablePid = (field: unknown): boolean => field === undefined || field === null || (Number.isSafeInteger(field) && (field as number) > 0);
|
|
154
|
+
const validLogs = record.logPids === undefined
|
|
155
|
+
|| (Array.isArray(record.logPids) && record.logPids.every(item => parseRegisteredLogTailer(item) !== undefined));
|
|
156
|
+
return typeof record.sessionId === 'string' && record.sessionId.length > 0
|
|
157
|
+
&& typeof record.dir === 'string'
|
|
158
|
+
&& (record.harId === null || typeof record.harId === 'string')
|
|
159
|
+
&& nullableString(record.startedAt)
|
|
160
|
+
&& nullableString(record.url)
|
|
161
|
+
&& (record.targetId === null || typeof record.targetId === 'string')
|
|
162
|
+
&& typeof record.stepCount === 'number'
|
|
163
|
+
&& Number.isSafeInteger(record.stepCount)
|
|
164
|
+
&& record.stepCount >= 0
|
|
165
|
+
&& validLogs
|
|
166
|
+
&& (record.port === undefined || record.port === null || (Number.isInteger(record.port) && (record.port as number) > 0 && (record.port as number) <= 65535))
|
|
167
|
+
&& nullableString(record.bridgeSocket)
|
|
168
|
+
&& nullablePid(record.bridgePid)
|
|
169
|
+
&& nullableString(record.activeRecId)
|
|
170
|
+
&& nullableString(record.stoppedAt)
|
|
171
|
+
&& (record.stopping === undefined || typeof record.stopping === 'boolean');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearActivePath(): void {
|
|
175
|
+
const activePath = getActivePath();
|
|
176
|
+
try {
|
|
177
|
+
unlinkPrivateFile(activePath);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
180
|
+
// A stale planted symlink is itself an index entry, never a target to follow.
|
|
181
|
+
try { removeArtifactTree(activePath); } catch { /* stale cleanup is best effort */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compare-clear (A2/U03): removes the active pointer only while the directory
|
|
187
|
+
* entry is still the exact inode the deciding read observed. A concurrent
|
|
188
|
+
* `session start` publishes its pointer as a fresh inode via rename, so a
|
|
189
|
+
* pointer republished after that read is never unlinked by a slow stale-clean
|
|
190
|
+
* and the scope's one-live-session cardinality is preserved.
|
|
191
|
+
*/
|
|
192
|
+
function clearActiveEntry(expected: ActiveEntryIdentity | null): void {
|
|
193
|
+
if (!expected) return;
|
|
194
|
+
const current = statActiveEntry();
|
|
195
|
+
if (!current || current.dev !== expected.dev || current.ino !== expected.ino) return;
|
|
196
|
+
clearActivePath();
|
|
24
197
|
}
|
|
25
198
|
|
|
26
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Reads active session metadata. Endpoint resolution uses `cleanStale: false`
|
|
201
|
+
* while it decides whether a malformed ambient CDP_PORT is relevant: that
|
|
202
|
+
* preserves a stale pointer for the invocation error path. Normal command
|
|
203
|
+
* consumers retain the default eager stale-index cleanup.
|
|
204
|
+
*/
|
|
205
|
+
export function getActiveSession({ cleanStale = true }: { cleanStale?: boolean } = {}): ActiveSessionState | null {
|
|
206
|
+
const pointer = readActivePointer();
|
|
207
|
+
const clean = (): void => { if (cleanStale) clearActiveEntry(pointer.entry); };
|
|
208
|
+
const index = pointer.index;
|
|
209
|
+
if (!index) {
|
|
210
|
+
clean();
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
27
214
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
215
|
+
const sessionDir = assertUnderCaptureRoot(path.resolve(index.dir));
|
|
216
|
+
if (sessionDir !== index.dir) throw new Error('active session directory escaped capture root');
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
readPrivateFile(path.join(sessionDir, 'bundle.json'));
|
|
220
|
+
clean();
|
|
221
|
+
return null;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const raw = readPrivateFile(sessionMetaPath(sessionDir)).toString('utf-8');
|
|
227
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
228
|
+
if (!isActiveStateCandidate(parsed)) {
|
|
229
|
+
clean();
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const state = parsed as ActiveSessionState;
|
|
234
|
+
if (state.sessionId !== index.sessionId || state.dir !== sessionDir) {
|
|
235
|
+
clean();
|
|
33
236
|
return null;
|
|
34
237
|
}
|
|
238
|
+
if (state.stoppedAt) {
|
|
239
|
+
clean();
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
ensurePrivateDir(sessionDir);
|
|
35
244
|
return state;
|
|
36
245
|
} catch {
|
|
246
|
+
// Any malformed/corrupt pointer or metadata file is stale. Endpoint
|
|
247
|
+
// precedence may inspect it without mutation; all ordinary callers clean.
|
|
248
|
+
clean();
|
|
37
249
|
return null;
|
|
38
250
|
}
|
|
39
251
|
}
|
|
40
252
|
|
|
41
|
-
export function setActiveSession(state: ActiveSessionState): void {
|
|
42
|
-
|
|
43
|
-
|
|
253
|
+
export async function setActiveSession(state: ActiveSessionState): Promise<void> {
|
|
254
|
+
const sessionDir = ensurePrivateDir(state.dir);
|
|
255
|
+
const next: ActiveSessionState = {
|
|
256
|
+
...state,
|
|
257
|
+
dir: path.resolve(sessionDir),
|
|
258
|
+
sessionId: state.sessionId,
|
|
259
|
+
harId: state.harId,
|
|
260
|
+
targetId: state.targetId,
|
|
261
|
+
stepCount: Number.isSafeInteger(state.stepCount) ? state.stepCount : 0,
|
|
262
|
+
};
|
|
263
|
+
const index: ActiveSessionIndex = { sessionId: next.sessionId, dir: next.dir };
|
|
264
|
+
|
|
265
|
+
await withSessionStateLock(next.dir, () => {
|
|
266
|
+
writeJsonPrivate(sessionMetaPath(next.dir), next);
|
|
267
|
+
writeJsonPrivate(getActivePath(), index);
|
|
268
|
+
});
|
|
44
269
|
}
|
|
45
270
|
|
|
46
271
|
export function clearActiveSession(): void {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
272
|
+
clearActivePath();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function clearActiveSessionIf(sessionId: string): void {
|
|
276
|
+
const pointer = readActivePointer();
|
|
277
|
+
if (pointer.index?.sessionId === sessionId) {
|
|
278
|
+
clearActiveEntry(pointer.entry);
|
|
51
279
|
}
|
|
52
280
|
}
|
|
53
281
|
|
|
54
|
-
|
|
282
|
+
async function mutateSessionState(
|
|
283
|
+
sessionDir: string,
|
|
284
|
+
mutate: (current: ActiveSessionState) => ActiveSessionState,
|
|
285
|
+
): Promise<ActiveSessionState> {
|
|
286
|
+
const canonical = assertUnderCaptureRoot(sessionDir);
|
|
287
|
+
return withSessionStateLock(canonical, () => {
|
|
288
|
+
const metaPath = sessionMetaPath(canonical);
|
|
289
|
+
const current = JSON.parse(readPrivateFile(metaPath).toString('utf-8')) as unknown;
|
|
290
|
+
if (!isActiveStateCandidate(current) || current.dir !== canonical) throw new Error(`invalid session metadata: ${metaPath}`);
|
|
291
|
+
const next = mutate(current);
|
|
292
|
+
const updated = { ...next, dir: current.dir, sessionId: current.sessionId };
|
|
293
|
+
if (!isActiveStateCandidate(updated)) throw new Error(`invalid session metadata update: ${metaPath}`);
|
|
294
|
+
writeJsonPrivate(metaPath, updated);
|
|
295
|
+
return updated;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function updateSessionState(sessionDir: string, patch: Partial<ActiveSessionState>): Promise<ActiveSessionState> {
|
|
300
|
+
return mutateSessionState(sessionDir, current => ({ ...current, ...patch }));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function updateActiveSession(patch: Partial<ActiveSessionState>): Promise<ActiveSessionState | null> {
|
|
55
304
|
const current = getActiveSession();
|
|
56
305
|
if (!current) return null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
306
|
+
return updateSessionState(current.dir, patch);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Records `recId` as the active session's live recording. */
|
|
310
|
+
export function setActiveRecId(recId: string): Promise<ActiveSessionState | null> {
|
|
311
|
+
return updateActiveSession({ activeRecId: recId });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Clears the active session's live-recording pointer (on `rec --stop` / stale reap). */
|
|
315
|
+
export function clearActiveRecId(): Promise<ActiveSessionState | null> {
|
|
316
|
+
return updateActiveSession({ activeRecId: null });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Reads the active session's live recording id, or `null` if none. */
|
|
320
|
+
export function getActiveRecId(): string | null {
|
|
321
|
+
return getActiveSession()?.activeRecId ?? null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Records the active session target's requested network emulation state. */
|
|
325
|
+
export function setActiveNetworkOffline(offline: boolean): ActiveSessionState | null {
|
|
326
|
+
return updateActiveSession({ networkOffline: offline });
|
|
60
327
|
}
|
|
61
328
|
|
|
62
329
|
function sanitizeLabel(label: string): string {
|
|
@@ -72,16 +339,17 @@ function sanitizeLabel(label: string): string {
|
|
|
72
339
|
* Format: {dir}/shots/{NN}-{action}-{sanitized-label}.png
|
|
73
340
|
* Increments stepCount in the session state.
|
|
74
341
|
*/
|
|
75
|
-
export function nextStepPath(action: string, label: string): string | null {
|
|
342
|
+
export async function nextStepPath(action: string, label: string): Promise<string | null> {
|
|
76
343
|
const session = getActiveSession();
|
|
77
344
|
if (!session) return null;
|
|
78
345
|
|
|
79
|
-
const
|
|
80
|
-
|
|
346
|
+
const updated = await mutateSessionState(session.dir, current => ({
|
|
347
|
+
...current,
|
|
348
|
+
stepCount: current.stepCount + 1,
|
|
349
|
+
}));
|
|
350
|
+
const nn = String(updated.stepCount).padStart(2, '0');
|
|
81
351
|
const sanitized = sanitizeLabel(label);
|
|
82
|
-
|
|
83
|
-
const shotPath = path.join(session.dir, 'shots', filename);
|
|
84
|
-
|
|
85
|
-
updateActiveSession({ stepCount: step });
|
|
86
|
-
return shotPath;
|
|
352
|
+
return path.join(updated.dir, 'shots', `${nn}-${action}-${sanitized}.png`);
|
|
87
353
|
}
|
|
354
|
+
|
|
355
|
+
export { type SnapMeta, type RecMeta };
|
package/src/vault/bundle.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* vault/bundle — the import-driven exec engine.
|
|
2
|
+
* vault/bundle — the import-driven `page exec` engine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* into a single self-contained IIFE: the user's `libs/<name>` imports are
|
|
9
|
-
* resolved to the forked vault source, esbuild bundles the whole tree on the
|
|
10
|
-
* fly, and the result is a string the exec path evals over CDP.
|
|
4
|
+
* When `capture page exec` receives code containing leading static imports,
|
|
5
|
+
* this turns it into a single self-contained IIFE: the user's `libs/<name>`
|
|
6
|
+
* imports are resolved to the forked vault source, esbuild bundles the whole
|
|
7
|
+
* tree on the fly, and the result is a string the exec path evaluates over CDP.
|
|
11
8
|
*
|
|
12
9
|
* No top-level `esbuild` import — it is loaded lazily via `loadEsbuild()` so the
|
|
13
10
|
* published package (no esbuild) never crashes at startup.
|
|
@@ -23,6 +20,7 @@ import {
|
|
|
23
20
|
vaultLibsDir,
|
|
24
21
|
vaultRepoRoot,
|
|
25
22
|
} from "./paths.js";
|
|
23
|
+
import { buildExecExpression } from "../cdp/exec-expression.js";
|
|
26
24
|
|
|
27
25
|
// Sentinel the post-bundle return-insert anchors on. esbuild with minify:false
|
|
28
26
|
// never renames top-level identifiers, and no first-party lib references
|
|
@@ -82,17 +80,24 @@ function preflightLibs(imports: string): void {
|
|
|
82
80
|
}
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
// Hoist the imports to module top level (so esbuild resolves & tree-shakes
|
|
86
|
-
// and
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
83
|
+
// Hoist the imports to module top level (so esbuild resolves & tree-shakes
|
|
84
|
+
// them) and bind the post-import body's VALUE to the sentinel. The body's
|
|
85
|
+
// semantics are owned by `buildExecExpression` — the same single authority
|
|
86
|
+
// plain (import-free) exec uses — so a natural final expression, explicit
|
|
87
|
+
// top-level return, top-level await, and a thrown exception behave
|
|
88
|
+
// identically with and without imports. `Promise.resolve` normalizes the
|
|
89
|
+
// built expression's value (a bare expression evaluates to a plain value,
|
|
90
|
+
// the wrapped forms to a promise) so the trailing `.then()` — a preserved
|
|
91
|
+
// no-op call → DCE-safe anchor for the post-bundle return insert (a bare
|
|
92
|
+
// `__CAPTURE_RESULT;` statement can be dropped by tree-shaking; a call
|
|
93
|
+
// survives) — always exists. The inner parens keep a comma expression one
|
|
94
|
+
// argument.
|
|
90
95
|
function buildEntry(code: string): string {
|
|
91
96
|
const { imports, body } = splitLeadingImports(code);
|
|
92
|
-
const
|
|
97
|
+
const expression = buildExecExpression(body);
|
|
93
98
|
return [
|
|
94
99
|
imports,
|
|
95
|
-
`const ${SENTINEL} = (
|
|
100
|
+
`const ${SENTINEL} = Promise.resolve((${expression}));`,
|
|
96
101
|
`${SENTINEL}.then();`,
|
|
97
102
|
].join("\n");
|
|
98
103
|
}
|
|
@@ -163,10 +168,20 @@ async function runEsbuild(entry: string): Promise<BuildResult<{ write: false }>>
|
|
|
163
168
|
// The only post-bundle edit: esbuild's iife wrapper `(() => { … })()` has no
|
|
164
169
|
// return, so the script completion value is undefined. Insert `return ` before
|
|
165
170
|
// the last `__CAPTURE_RESULT.then(` so the IIFE returns the user's promise.
|
|
166
|
-
|
|
171
|
+
// The anchor is an invariant `buildEntry` establishes (the preserved `.then()`
|
|
172
|
+
// call survives tree-shaking, and minify:false keeps the sentinel name), so a
|
|
173
|
+
// missing anchor is a broken bundle — evaluating it would silently complete
|
|
174
|
+
// with undefined instead of the code's value. Hard error, never a returnless
|
|
175
|
+
// bundle. Exported for its focused test (test/bundle.test.ts).
|
|
176
|
+
export function applyReturnInsert(bundled: string): string {
|
|
167
177
|
const anchor = `${SENTINEL}.then(`;
|
|
168
178
|
const i = bundled.lastIndexOf(anchor);
|
|
169
|
-
|
|
179
|
+
if (i === -1) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`bundled exec output lost its ${SENTINEL}.then( return anchor — the bundle would complete with undefined instead of the code's value`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return bundled.slice(0, i) + "return " + bundled.slice(i);
|
|
170
185
|
}
|
|
171
186
|
|
|
172
187
|
function formatBuildErrors(errors: Message[]): string {
|
package/src/cdp/commands/exec.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import { withConnection } from '../connection.js';
|
|
3
|
-
import { buildExecExpression } from '../exec-expression.js';
|
|
4
|
-
import { HARRecorder } from '../har-recorder.js';
|
|
5
|
-
import { writeHarAndPrintSummary } from '../har-output.js';
|
|
6
|
-
import { type ParsedArgs } from '../types.js';
|
|
7
|
-
import { hasImports, bundleExec } from '../../vault/bundle.js';
|
|
8
|
-
|
|
9
|
-
export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
10
|
-
if (parsed.help) {
|
|
11
|
-
console.log(
|
|
12
|
-
'Usage: capture exec <code> [--target <id>] [--record] [--file <path>] [--har <id>]\n\n' +
|
|
13
|
-
'Execute JavaScript in a browser tab. Plain expressions run directly; statement bodies can use top-level return/await and are wrapped in an async IIFE.\n\n' +
|
|
14
|
-
'Examples:\n' +
|
|
15
|
-
' capture exec "document.title" --target <id>\n' +
|
|
16
|
-
' capture exec "return document.title" --target <id>\n' +
|
|
17
|
-
' capture exec "return await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
|
|
18
|
-
' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
|
|
19
|
-
' capture exec --file /tmp/scrape.js --target <id> --record\n\n' +
|
|
20
|
-
'Import vault libs (dev checkout only): static imports first, then your code.\n' +
|
|
21
|
-
' capture exec "import {searchEmails} from \'libs/gmail\'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: \'invoice\' })"',
|
|
22
|
-
);
|
|
23
|
-
process.exit(0);
|
|
24
|
-
}
|
|
25
|
-
let code: string;
|
|
26
|
-
if (parsed.file) {
|
|
27
|
-
code = fs.readFileSync(parsed.file, 'utf-8');
|
|
28
|
-
} else {
|
|
29
|
-
code = parsed.positional[0];
|
|
30
|
-
if (!code) {
|
|
31
|
-
console.error(
|
|
32
|
-
'ERROR: Missing code to execute.\n\n' +
|
|
33
|
-
'Usage: capture exec <code> [--target <id>] [--record] [--file <path>]\n\n' +
|
|
34
|
-
'Supported shapes:\n' +
|
|
35
|
-
' document.title\n' +
|
|
36
|
-
' return document.title\n' +
|
|
37
|
-
' const r = await fetch(\'/api/data\'); return await r.json()\n\n' +
|
|
38
|
-
'Examples:\n' +
|
|
39
|
-
' capture exec "document.title" --target <id>\n' +
|
|
40
|
-
' capture exec "return document.title" --target <id>\n' +
|
|
41
|
-
' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
|
|
42
|
-
' capture exec --file /tmp/scrape.js --target <id> --record',
|
|
43
|
-
);
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Import-driven exec: if the code has leading static imports, bundle the
|
|
49
|
-
// forked vault libs on the fly (esbuild) BEFORE opening a tab — fail fast,
|
|
50
|
-
// no wasted CDP connection. Plain exec (no imports) skips this entirely.
|
|
51
|
-
let prebuilt: string | undefined;
|
|
52
|
-
if (hasImports(code)) {
|
|
53
|
-
try {
|
|
54
|
-
prebuilt = await bundleExec(code);
|
|
55
|
-
} catch (e) {
|
|
56
|
-
console.error(`ERROR: ${(e as Error).message}`);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
await withConnection(
|
|
62
|
-
parsed,
|
|
63
|
-
async (client) => {
|
|
64
|
-
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
65
|
-
|
|
66
|
-
// Standalone HAR recording (--record flag writes its own file)
|
|
67
|
-
let standaloneRecorder: HARRecorder | undefined;
|
|
68
|
-
if (parsed.record) {
|
|
69
|
-
standaloneRecorder = new HARRecorder(client);
|
|
70
|
-
await standaloneRecorder.start();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// A prebuilt bundle is already a complete IIFE returning the user's
|
|
74
|
-
// promise. Otherwise fall back to the exec expression wrapper: bare
|
|
75
|
-
// expressions run directly, while snippets containing top-level `return`
|
|
76
|
-
// or `await` are wrapped in an async IIFE.
|
|
77
|
-
const expression = buildExecExpression(code, prebuilt);
|
|
78
|
-
|
|
79
|
-
const evalResult = (await client.send('Runtime.evaluate', {
|
|
80
|
-
expression,
|
|
81
|
-
awaitPromise: true,
|
|
82
|
-
returnByValue: true,
|
|
83
|
-
})) as {
|
|
84
|
-
result?: { value?: unknown };
|
|
85
|
-
exceptionDetails?: { exception?: { description?: string } };
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
if (standaloneRecorder) {
|
|
89
|
-
const har = await standaloneRecorder.finish();
|
|
90
|
-
writeHarAndPrintSummary(har, parsed.harOut);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (evalResult.exceptionDetails) {
|
|
94
|
-
console.error(
|
|
95
|
-
`ERROR: ${evalResult.exceptionDetails.exception?.description ?? 'Unknown error'}`,
|
|
96
|
-
);
|
|
97
|
-
process.exit(1);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
console.log(JSON.stringify(evalResult.result?.value));
|
|
101
|
-
},
|
|
102
|
-
{ settle: 3000 },
|
|
103
|
-
);
|
|
104
|
-
return;
|
|
105
|
-
}
|