@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.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -1,62 +1,329 @@
1
1
  /**
2
- * Session Context — persists active session state so CDP commands
3
- * can auto-fill --target, --har, and --out without manual threading.
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
- * State is written to /tmp/capture-sessions/.active (JSON).
6
- * Stale sessions (dir deleted, crashed process) are cleaned up on read.
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 * as os from 'os';
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
- const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
14
- const ACTIVE_PATH = path.join(CAPTURE_ROOT, '.active');
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
- export function getActiveSession(): ActiveSessionState | null {
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
- if (!fs.existsSync(ACTIVE_PATH)) return null;
29
- const state = JSON.parse(fs.readFileSync(ACTIVE_PATH, 'utf-8')) as ActiveSessionState;
30
- // Validate session dir still exists — clean up stale files
31
- if (!fs.existsSync(state.dir)) {
32
- clearActiveSession();
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
- fs.mkdirSync(CAPTURE_ROOT, { recursive: true });
43
- fs.writeFileSync(ACTIVE_PATH, JSON.stringify(state, null, 2));
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
- try {
48
- fs.unlinkSync(ACTIVE_PATH);
49
- } catch {
50
- // Already gone
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
- export function updateActiveSession(patch: Partial<ActiveSessionState>): ActiveSessionState | null {
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
- const updated = { ...current, ...patch };
58
- setActiveSession(updated);
59
- return updated;
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 step = session.stepCount + 1;
80
- const nn = String(step).padStart(2, '0');
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
- const filename = `${nn}-${action}-${sanitized}.png`;
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 };
@@ -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
- * Ports the bundling mechanism of northlight's
5
- * `apps/core/src/tool-registry/lib/bundle-agent-code.ts`, DROPPING the telemetry
6
- * envelope (no `__vallumStartCall`, no `__snapshot`, no `borgEnabled`). When
7
- * `capture exec` is handed code containing leading static imports, this turns it
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 them)
86
- // and wrap the body in a result-capturing async IIFE bound to the sentinel. The
87
- // trailing `.then()` is a preserved no-op call → DCE-safe anchor for the
88
- // post-bundle return insert (a bare `__CAPTURE_RESULT;` statement can be dropped
89
- // by tree-shaking; a call survives).
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 expr = body.trim().replace(/;+\s*$/, "");
97
+ const expression = buildExecExpression(body);
93
98
  return [
94
99
  imports,
95
- `const ${SENTINEL} = (async () => { ${expr} })();`,
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
- function applyReturnInsert(bundled: string): string {
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
- return i === -1 ? bundled : bundled.slice(0, i) + "return " + bundled.slice(i);
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 {
@@ -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
- }