@crouton-kit/capture 1.3.3 → 1.3.4

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.
@@ -0,0 +1,385 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { spawn } from 'child_process';
5
+ import {
6
+ createHarRecording,
7
+ readHarRecording,
8
+ deleteHarRecording,
9
+ } from '../har-manager.js';
10
+ import {
11
+ getActiveSession,
12
+ setActiveSession,
13
+ clearActiveSession,
14
+ } from '../session-context.js';
15
+
16
+ const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
17
+
18
+ interface LogPid {
19
+ pid: number;
20
+ name: string;
21
+ sourcePath: string;
22
+ }
23
+
24
+ interface Session {
25
+ id: string;
26
+ dir: string;
27
+ harId: string | null;
28
+ startedAt: string;
29
+ url: string | null;
30
+ targetId: string | null;
31
+ stepCount: number;
32
+ logPids: LogPid[];
33
+ }
34
+
35
+ interface BundleManifest {
36
+ id: string;
37
+ startedAt: string;
38
+ stoppedAt: string;
39
+ duration: number;
40
+ url: string | null;
41
+ screenshots: Array<{ name: string; path: string }>;
42
+ har: { id: string; path: string; entryCount: number } | null;
43
+ a11y: Array<{ name: string; path: string }>;
44
+ logs: Array<{ name: string; path: string; lines: number }>;
45
+ other: Array<{ name: string; path: string }>;
46
+ }
47
+
48
+ function sessionDir(id: string): string {
49
+ return path.join(CAPTURE_ROOT, id);
50
+ }
51
+
52
+ function sessionMetaPath(id: string): string {
53
+ return path.join(sessionDir(id), '.session.json');
54
+ }
55
+
56
+ function readSession(id: string): Session {
57
+ const metaPath = sessionMetaPath(id);
58
+ if (!fs.existsSync(metaPath)) {
59
+ throw new Error(`No capture session found: ${id}`);
60
+ }
61
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Session;
62
+ }
63
+
64
+ function generateId(): string {
65
+ return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Session Commands
70
+ // ============================================================================
71
+
72
+ async function start(args: string[]): Promise<void> {
73
+ let url: string | null = null;
74
+
75
+ for (let i = 0; i < args.length; i++) {
76
+ if (args[i] === '--url' && args[i + 1]) {
77
+ url = args[++i];
78
+ }
79
+ }
80
+
81
+ const id = generateId();
82
+ const dir = sessionDir(id);
83
+ fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
84
+ fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
85
+
86
+ // Start HAR recording directly
87
+ let harId: string | null = null;
88
+ try {
89
+ const harResult = createHarRecording();
90
+ harId = harResult.id;
91
+ } catch (err) {
92
+ console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
93
+ }
94
+
95
+ // Open tab if URL provided
96
+ let targetId: string | null = null;
97
+ if (url) {
98
+ try {
99
+ const { detectCdpPort, navigateAndWait } = await import('../cdp.js');
100
+ const port = await detectCdpPort();
101
+ const tab = await navigateAndWait(port, url);
102
+ targetId = tab.id;
103
+ } catch (err) {
104
+ console.error(`Warning: could not open tab: ${err instanceof Error ? err.message : err}`);
105
+ }
106
+ }
107
+
108
+ const session: Session = {
109
+ id,
110
+ dir,
111
+ harId,
112
+ startedAt: new Date().toISOString(),
113
+ url,
114
+ targetId,
115
+ stepCount: 0,
116
+ logPids: [],
117
+ };
118
+ fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
119
+
120
+ // Set as active session for auto-defaults
121
+ setActiveSession({
122
+ sessionId: id,
123
+ dir,
124
+ harId,
125
+ targetId,
126
+ stepCount: 0,
127
+ });
128
+
129
+ // Output for agent consumption
130
+ const result = {
131
+ sessionId: id,
132
+ bundleDir: dir,
133
+ harId,
134
+ targetId,
135
+ shotsDir: path.join(dir, 'shots'),
136
+ a11yDir: path.join(dir, 'a11y'),
137
+ };
138
+ console.log(JSON.stringify(result, null, 2));
139
+
140
+ // Agent-friendly next steps on stderr
141
+ console.error(`\nCapture session started: ${id}`);
142
+ if (targetId) {
143
+ console.error(`Tab opened — session context active. No need to pass --target or --har.`);
144
+ }
145
+ console.error(`\nWhen done: capture session stop ${id}`);
146
+ }
147
+
148
+ export function logCommand(args: string[]): void {
149
+ const sourcePath = args[0];
150
+ if (!sourcePath) {
151
+ console.error('Usage: capture log <path> [--name label] [--session <id>]');
152
+ process.exit(1);
153
+ }
154
+
155
+ const resolved = path.resolve(sourcePath);
156
+ if (!fs.existsSync(resolved)) {
157
+ throw new Error(`Log file not found: ${resolved}`);
158
+ }
159
+
160
+ let name: string | null = null;
161
+ let sessionId: string | null = null;
162
+ for (let i = 1; i < args.length; i++) {
163
+ if (args[i] === '--name' && args[i + 1]) name = args[++i];
164
+ if (args[i] === '--session' && args[i + 1]) sessionId = args[++i];
165
+ }
166
+
167
+ if (!sessionId) {
168
+ const active = getActiveSession();
169
+ if (!active) {
170
+ throw new Error('No active capture session. Start one or pass --session <id>.');
171
+ }
172
+ sessionId = active.sessionId;
173
+ }
174
+
175
+ const session = readSession(sessionId);
176
+ name = name ?? path.basename(resolved, path.extname(resolved));
177
+
178
+ const logsDir = path.join(session.dir, 'logs');
179
+ fs.mkdirSync(logsDir, { recursive: true });
180
+
181
+ const destPath = path.join(logsDir, `${name}.log`);
182
+ const outFd = fs.openSync(destPath, 'a');
183
+
184
+ const child = spawn(
185
+ 'sh',
186
+ ['-c', `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
187
+ { detached: true, stdio: ['ignore', outFd, 'ignore'] },
188
+ );
189
+ child.unref();
190
+ fs.closeSync(outFd);
191
+
192
+ const pid = child.pid!;
193
+ session.logPids.push({ pid, name, sourcePath: resolved });
194
+ fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
195
+
196
+ console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
197
+ }
198
+
199
+ async function stop(args: string[]): Promise<void> {
200
+ const id = args[0];
201
+ if (!id) {
202
+ console.error('Usage: capture session stop <session-id>');
203
+ process.exit(1);
204
+ }
205
+
206
+ const session = readSession(id);
207
+ const stoppedAt = new Date().toISOString();
208
+ const startMs = new Date(session.startedAt).getTime();
209
+ const duration = Date.now() - startMs;
210
+
211
+ // Kill log tailers
212
+ for (const lp of session.logPids ?? []) {
213
+ try { process.kill(-lp.pid, 'SIGTERM'); } catch { /* already dead */ }
214
+ }
215
+ if (session.logPids?.length) {
216
+ await new Promise((r) => setTimeout(r, 200));
217
+ }
218
+
219
+ // Collect screenshots
220
+ const shotsDir = path.join(session.dir, 'shots');
221
+ const screenshots = fs.existsSync(shotsDir)
222
+ ? fs.readdirSync(shotsDir)
223
+ .filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
224
+ .map((f) => ({ name: f, path: path.join(shotsDir, f) }))
225
+ : [];
226
+
227
+ // Collect a11y snapshots
228
+ const a11yDir = path.join(session.dir, 'a11y');
229
+ const a11y = fs.existsSync(a11yDir)
230
+ ? fs.readdirSync(a11yDir)
231
+ .filter((f) => f.endsWith('.json') || f.endsWith('.txt'))
232
+ .map((f) => ({ name: f, path: path.join(a11yDir, f) }))
233
+ : [];
234
+
235
+ // Collect HAR directly from har-manager
236
+ let har: BundleManifest['har'] = null;
237
+ if (session.harId) {
238
+ try {
239
+ const harData = readHarRecording(session.harId);
240
+ if (harData) {
241
+ const harPath = path.join(session.dir, 'har.json');
242
+ fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
243
+ har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
244
+ // Clean up the HAR recording
245
+ try { deleteHarRecording(session.harId); } catch { /* best effort */ }
246
+ }
247
+ } catch (err) {
248
+ console.error(`Warning: could not read HAR: ${err instanceof Error ? err.message : err}`);
249
+ }
250
+ }
251
+
252
+ // Collect log files
253
+ const logsDir = path.join(session.dir, 'logs');
254
+ const logs = fs.existsSync(logsDir)
255
+ ? fs.readdirSync(logsDir)
256
+ .filter((f) => f.endsWith('.log'))
257
+ .map((f) => {
258
+ const filePath = path.join(logsDir, f);
259
+ const content = fs.readFileSync(filePath, 'utf-8');
260
+ const lines = content ? content.split('\n').filter(Boolean).length : 0;
261
+ return { name: f, path: filePath, lines };
262
+ })
263
+ : [];
264
+
265
+ // Collect anything else dropped in the session dir
266
+ const knownDirs = new Set(['shots', 'a11y', 'logs']);
267
+ const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json']);
268
+ const other = fs.readdirSync(session.dir)
269
+ .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
270
+ .map((f) => ({ name: f, path: path.join(session.dir, f) }));
271
+
272
+ // Clear active session context
273
+ clearActiveSession();
274
+
275
+ const manifest: BundleManifest = {
276
+ id: session.id,
277
+ startedAt: session.startedAt,
278
+ stoppedAt,
279
+ duration,
280
+ url: session.url,
281
+ screenshots,
282
+ har,
283
+ a11y,
284
+ logs,
285
+ other,
286
+ };
287
+
288
+ const bundlePath = path.join(session.dir, 'bundle.json');
289
+ fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
290
+
291
+ console.log(JSON.stringify({
292
+ bundlePath,
293
+ summary: {
294
+ duration,
295
+ screenshots: screenshots.length,
296
+ harEntries: har?.entryCount ?? 0,
297
+ a11ySnapshots: a11y.length,
298
+ logFiles: logs.length,
299
+ otherFiles: other.length,
300
+ },
301
+ }, null, 2));
302
+
303
+ console.error(`\nBundle written: ${bundlePath}`);
304
+ console.error(`Read it: capture session view ${id}`);
305
+ }
306
+
307
+ function list(): void {
308
+ if (!fs.existsSync(CAPTURE_ROOT)) {
309
+ console.log('[]');
310
+ return;
311
+ }
312
+
313
+ const sessions = fs.readdirSync(CAPTURE_ROOT)
314
+ .filter((d) => fs.existsSync(sessionMetaPath(d)))
315
+ .map((d) => {
316
+ const session = readSession(d);
317
+ const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
318
+ return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
319
+ });
320
+
321
+ console.log(JSON.stringify(sessions, null, 2));
322
+ }
323
+
324
+ function view(args: string[]): void {
325
+ const id = args[0];
326
+ if (!id) {
327
+ console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
328
+ process.exit(1);
329
+ }
330
+
331
+ const session = readSession(id);
332
+ const bundlePath = path.join(session.dir, 'bundle.json');
333
+
334
+ if (!fs.existsSync(bundlePath)) {
335
+ console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
336
+ process.exit(1);
337
+ }
338
+
339
+ const manifest = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as BundleManifest;
340
+
341
+ const filter = args.find((_, i) => args[i - 1] === '--filter');
342
+ if (filter) {
343
+ const section = manifest[filter as keyof BundleManifest];
344
+ console.log(JSON.stringify(section, null, 2));
345
+ } else {
346
+ console.log(JSON.stringify(manifest, null, 2));
347
+ }
348
+ }
349
+
350
+ export async function sessionMain(args: string[]): Promise<void> {
351
+ const [subcommand, ...rest] = args;
352
+
353
+ switch (subcommand) {
354
+ case 'start': return start(rest);
355
+ case 'stop': return stop(rest);
356
+ case 'list': return list();
357
+ case 'view': return view(rest);
358
+ default:
359
+ console.log(`capture session — manage capture sessions
360
+
361
+ Sub-commands:
362
+ start [--url <url>] Start a session (opens tab, records HAR, sets active context)
363
+ stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
364
+ list List active and stopped sessions
365
+ view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
366
+
367
+ Why sessions: once started, every subsequent capture command auto-fills
368
+ --target (the tab) and --har (the recording). No manual flag threading.
369
+
370
+ Typical flow:
371
+ 1. capture session start --url http://localhost:3000
372
+ 2. Interact — no --target / --har needed:
373
+ capture a11y --interactive
374
+ capture click "Sign in"
375
+ capture type "hi@me.com" --into "Email"
376
+ capture screenshot
377
+ capture navigate https://app.example.com/dashboard
378
+ capture har read --filter-url /api
379
+ 3. capture session stop <session-id>
380
+ 4. capture session view <session-id>
381
+
382
+ Related: capture log <path> [--name label] Tail a log into the active session
383
+ See also: capture --help Full command list`);
384
+ }
385
+ }