@crouton-kit/capture 1.3.1

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crouton-kit/capture",
3
+ "version": "1.3.1",
4
+ "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
+ "bin": {
6
+ "capture": "./bin/capture"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "build": "esbuild src/capture.ts --bundle --platform=node --format=cjs --outfile=bin/capture '--banner:js=#!/usr/bin/env node' && chmod +x bin/capture"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/CaptainCrouton89/capture.git"
18
+ },
19
+ "keywords": [
20
+ "cdp",
21
+ "chrome-devtools-protocol",
22
+ "browser-automation",
23
+ "har",
24
+ "screenshot",
25
+ "accessibility",
26
+ "a11y",
27
+ "testing"
28
+ ],
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "devDependencies": {
34
+ "@types/ws": "^8.18.1",
35
+ "esbuild": "^0.24.0",
36
+ "typescript": "^5.7.0"
37
+ },
38
+ "dependencies": {
39
+ "ws": "^8.19.0"
40
+ }
41
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Capture — unified CLI for session management and CDP browser automation.
3
+ *
4
+ * Session commands (namespaced):
5
+ * capture session start [--url <url>]
6
+ * capture session stop <session-id>
7
+ * capture session list
8
+ * capture session view <session-id> [--filter screenshots|har|a11y]
9
+ * capture log <path> [--name label]
10
+ *
11
+ * CDP commands (top-level):
12
+ * capture detect Detect CDP port
13
+ * capture exec <code> Execute JS in a tab
14
+ * capture list List browser tabs
15
+ * capture open <url> Open URL in browser
16
+ * capture screenshot Capture screenshot
17
+ * capture a11y Get accessibility tree
18
+ * capture record Passive HAR recording
19
+ * capture navigate <url> Navigate + record HAR
20
+ * capture har create|read|delete Manage HAR recordings
21
+ */
22
+
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ import { spawn } from "child_process";
27
+ import {
28
+ createHarRecording,
29
+ readHarRecording,
30
+ deleteHarRecording,
31
+ } from "./har-manager.js";
32
+ import { cdpMain } from "./cdp.js";
33
+ import {
34
+ getActiveSession,
35
+ setActiveSession,
36
+ clearActiveSession,
37
+ } from "./session-context.js";
38
+
39
+ const CAPTURE_ROOT = path.join(os.tmpdir(), "capture-sessions");
40
+
41
+ interface LogPid {
42
+ pid: number;
43
+ name: string;
44
+ sourcePath: string;
45
+ }
46
+
47
+ interface Session {
48
+ id: string;
49
+ dir: string;
50
+ harId: string | null;
51
+ startedAt: string;
52
+ url: string | null;
53
+ targetId: string | null;
54
+ stepCount: number;
55
+ logPids: LogPid[];
56
+ }
57
+
58
+ interface BundleManifest {
59
+ id: string;
60
+ startedAt: string;
61
+ stoppedAt: string;
62
+ duration: number;
63
+ url: string | null;
64
+ screenshots: Array<{ name: string; path: string }>;
65
+ har: { id: string; path: string; entryCount: number } | null;
66
+ a11y: Array<{ name: string; path: string }>;
67
+ logs: Array<{ name: string; path: string; lines: number }>;
68
+ other: Array<{ name: string; path: string }>;
69
+ }
70
+
71
+ function sessionDir(id: string): string {
72
+ return path.join(CAPTURE_ROOT, id);
73
+ }
74
+
75
+ function sessionMetaPath(id: string): string {
76
+ return path.join(sessionDir(id), ".session.json");
77
+ }
78
+
79
+ function readSession(id: string): Session {
80
+ const metaPath = sessionMetaPath(id);
81
+ if (!fs.existsSync(metaPath)) {
82
+ throw new Error(`No capture session found: ${id}`);
83
+ }
84
+ return JSON.parse(fs.readFileSync(metaPath, "utf-8")) as Session;
85
+ }
86
+
87
+ function generateId(): string {
88
+ return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
89
+ }
90
+
91
+ // ============================================================================
92
+ // Session Commands
93
+ // ============================================================================
94
+
95
+ async function start(args: string[]): Promise<void> {
96
+ let url: string | null = null;
97
+
98
+ for (let i = 0; i < args.length; i++) {
99
+ if (args[i] === "--url" && args[i + 1]) {
100
+ url = args[++i];
101
+ }
102
+ }
103
+
104
+ const id = generateId();
105
+ const dir = sessionDir(id);
106
+ fs.mkdirSync(path.join(dir, "shots"), { recursive: true });
107
+ fs.mkdirSync(path.join(dir, "a11y"), { recursive: true });
108
+
109
+ // Start HAR recording directly
110
+ let harId: string | null = null;
111
+ try {
112
+ const harResult = createHarRecording();
113
+ harId = harResult.id;
114
+ } catch (err) {
115
+ console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
116
+ }
117
+
118
+ // Open tab if URL provided
119
+ let targetId: string | null = null;
120
+ if (url) {
121
+ try {
122
+ const { detectCdpPort, navigateAndWait } = await import('./cdp.js');
123
+ const port = await detectCdpPort();
124
+ const tab = await navigateAndWait(port, url);
125
+ targetId = tab.id;
126
+ } catch (err) {
127
+ console.error(`Warning: could not open tab: ${err instanceof Error ? err.message : err}`);
128
+ }
129
+ }
130
+
131
+ const session: Session = {
132
+ id,
133
+ dir,
134
+ harId,
135
+ startedAt: new Date().toISOString(),
136
+ url,
137
+ targetId,
138
+ stepCount: 0,
139
+ logPids: [],
140
+ };
141
+ fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
142
+
143
+ // Set as active session for auto-defaults
144
+ setActiveSession({
145
+ sessionId: id,
146
+ dir,
147
+ harId,
148
+ targetId,
149
+ stepCount: 0,
150
+ });
151
+
152
+ // Output for agent consumption
153
+ const result = {
154
+ sessionId: id,
155
+ bundleDir: dir,
156
+ harId,
157
+ targetId,
158
+ shotsDir: path.join(dir, "shots"),
159
+ a11yDir: path.join(dir, "a11y"),
160
+ };
161
+ console.log(JSON.stringify(result, null, 2));
162
+
163
+ // Agent-friendly next steps on stderr
164
+ console.error(`\nCapture session started: ${id}`);
165
+ if (targetId) {
166
+ console.error(`Tab opened — session context active. No need to pass --target or --har.`);
167
+ }
168
+ console.error(`\nWhen done: capture session stop ${id}`);
169
+ }
170
+
171
+ function logCommand(args: string[]): void {
172
+ const sourcePath = args[0];
173
+ if (!sourcePath) {
174
+ console.error("Usage: capture log <path> [--name label] [--session <id>]");
175
+ process.exit(1);
176
+ }
177
+
178
+ const resolved = path.resolve(sourcePath);
179
+ if (!fs.existsSync(resolved)) {
180
+ throw new Error(`Log file not found: ${resolved}`);
181
+ }
182
+
183
+ let name: string | null = null;
184
+ let sessionId: string | null = null;
185
+ for (let i = 1; i < args.length; i++) {
186
+ if (args[i] === "--name" && args[i + 1]) name = args[++i];
187
+ if (args[i] === "--session" && args[i + 1]) sessionId = args[++i];
188
+ }
189
+
190
+ if (!sessionId) {
191
+ const active = getActiveSession();
192
+ if (!active) {
193
+ throw new Error("No active capture session. Start one or pass --session <id>.");
194
+ }
195
+ sessionId = active.sessionId;
196
+ }
197
+
198
+ const session = readSession(sessionId);
199
+ name = name ?? path.basename(resolved, path.extname(resolved));
200
+
201
+ const logsDir = path.join(session.dir, "logs");
202
+ fs.mkdirSync(logsDir, { recursive: true });
203
+
204
+ const destPath = path.join(logsDir, `${name}.log`);
205
+ const outFd = fs.openSync(destPath, "a");
206
+
207
+ const child = spawn(
208
+ "sh",
209
+ ["-c", `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
210
+ { detached: true, stdio: ["ignore", outFd, "ignore"] },
211
+ );
212
+ child.unref();
213
+ fs.closeSync(outFd);
214
+
215
+ const pid = child.pid!;
216
+ session.logPids.push({ pid, name, sourcePath: resolved });
217
+ fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
218
+
219
+ console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
220
+ }
221
+
222
+ async function stop(args: string[]): Promise<void> {
223
+ const id = args[0];
224
+ if (!id) {
225
+ console.error("Usage: capture session stop <session-id>");
226
+ process.exit(1);
227
+ }
228
+
229
+ const session = readSession(id);
230
+ const stoppedAt = new Date().toISOString();
231
+ const startMs = new Date(session.startedAt).getTime();
232
+ const duration = Date.now() - startMs;
233
+
234
+ // Kill log tailers
235
+ for (const lp of session.logPids ?? []) {
236
+ try { process.kill(-lp.pid, "SIGTERM"); } catch { /* already dead */ }
237
+ }
238
+ if (session.logPids?.length) {
239
+ await new Promise((r) => setTimeout(r, 200));
240
+ }
241
+
242
+ // Collect screenshots
243
+ const shotsDir = path.join(session.dir, "shots");
244
+ const screenshots = fs.existsSync(shotsDir)
245
+ ? fs.readdirSync(shotsDir)
246
+ .filter((f) => f.endsWith(".png") || f.endsWith(".jpg"))
247
+ .map((f) => ({ name: f, path: path.join(shotsDir, f) }))
248
+ : [];
249
+
250
+ // Collect a11y snapshots
251
+ const a11yDir = path.join(session.dir, "a11y");
252
+ const a11y = fs.existsSync(a11yDir)
253
+ ? fs.readdirSync(a11yDir)
254
+ .filter((f) => f.endsWith(".json") || f.endsWith(".txt"))
255
+ .map((f) => ({ name: f, path: path.join(a11yDir, f) }))
256
+ : [];
257
+
258
+ // Collect HAR directly from har-manager
259
+ let har: BundleManifest["har"] = null;
260
+ if (session.harId) {
261
+ try {
262
+ const harData = readHarRecording(session.harId);
263
+ if (harData) {
264
+ const harPath = path.join(session.dir, "har.json");
265
+ fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
266
+ har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
267
+ // Clean up the HAR recording
268
+ try { deleteHarRecording(session.harId); } catch { /* best effort */ }
269
+ }
270
+ } catch (err) {
271
+ console.error(`Warning: could not read HAR: ${err instanceof Error ? err.message : err}`);
272
+ }
273
+ }
274
+
275
+ // Collect log files
276
+ const logsDir = path.join(session.dir, "logs");
277
+ const logs = fs.existsSync(logsDir)
278
+ ? fs.readdirSync(logsDir)
279
+ .filter((f) => f.endsWith(".log"))
280
+ .map((f) => {
281
+ const filePath = path.join(logsDir, f);
282
+ const content = fs.readFileSync(filePath, "utf-8");
283
+ const lines = content ? content.split("\n").filter(Boolean).length : 0;
284
+ return { name: f, path: filePath, lines };
285
+ })
286
+ : [];
287
+
288
+ // Collect anything else dropped in the session dir
289
+ const knownDirs = new Set(["shots", "a11y", "logs"]);
290
+ const knownFiles = new Set([".session.json", "har.json", "bundle.json"]);
291
+ const other = fs.readdirSync(session.dir)
292
+ .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
293
+ .map((f) => ({ name: f, path: path.join(session.dir, f) }));
294
+
295
+ // Clear active session context
296
+ clearActiveSession();
297
+
298
+ const manifest: BundleManifest = {
299
+ id: session.id,
300
+ startedAt: session.startedAt,
301
+ stoppedAt,
302
+ duration,
303
+ url: session.url,
304
+ screenshots,
305
+ har,
306
+ a11y,
307
+ logs,
308
+ other,
309
+ };
310
+
311
+ const bundlePath = path.join(session.dir, "bundle.json");
312
+ fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
313
+
314
+ console.log(JSON.stringify({
315
+ bundlePath,
316
+ summary: {
317
+ duration,
318
+ screenshots: screenshots.length,
319
+ harEntries: har?.entryCount ?? 0,
320
+ a11ySnapshots: a11y.length,
321
+ logFiles: logs.length,
322
+ otherFiles: other.length,
323
+ },
324
+ }, null, 2));
325
+
326
+ console.error(`\nBundle written: ${bundlePath}`);
327
+ console.error(`Read it: capture session view ${id}`);
328
+ }
329
+
330
+ function list(): void {
331
+ if (!fs.existsSync(CAPTURE_ROOT)) {
332
+ console.log("[]");
333
+ return;
334
+ }
335
+
336
+ const sessions = fs.readdirSync(CAPTURE_ROOT)
337
+ .filter((d) => fs.existsSync(sessionMetaPath(d)))
338
+ .map((d) => {
339
+ const session = readSession(d);
340
+ const hasBundled = fs.existsSync(path.join(session.dir, "bundle.json"));
341
+ return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? "stopped" : "active" };
342
+ });
343
+
344
+ console.log(JSON.stringify(sessions, null, 2));
345
+ }
346
+
347
+ function view(args: string[]): void {
348
+ const id = args[0];
349
+ if (!id) {
350
+ console.error("Usage: capture session view <session-id> [--filter screenshots|har|a11y]");
351
+ process.exit(1);
352
+ }
353
+
354
+ const session = readSession(id);
355
+ const bundlePath = path.join(session.dir, "bundle.json");
356
+
357
+ if (!fs.existsSync(bundlePath)) {
358
+ console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
359
+ process.exit(1);
360
+ }
361
+
362
+ const manifest = JSON.parse(fs.readFileSync(bundlePath, "utf-8")) as BundleManifest;
363
+
364
+ const filter = args.find((_, i) => args[i - 1] === "--filter");
365
+ if (filter) {
366
+ const section = manifest[filter as keyof BundleManifest];
367
+ console.log(JSON.stringify(section, null, 2));
368
+ } else {
369
+ console.log(JSON.stringify(manifest, null, 2));
370
+ }
371
+ }
372
+
373
+ async function sessionMain(args: string[]): Promise<void> {
374
+ const [subcommand, ...rest] = args;
375
+
376
+ switch (subcommand) {
377
+ case "start": return start(rest);
378
+ case "stop": return stop(rest);
379
+ case "list": return list();
380
+ case "view": return view(rest);
381
+ default:
382
+ console.log(`capture session — manage capture sessions
383
+
384
+ Commands:
385
+ start [--url <url>] Start a capture session
386
+ stop <session-id> Finalize and bundle all artifacts
387
+ list List active and stopped sessions
388
+ view <id> [--filter section] View bundle manifest
389
+ log <path> [--name label] Tail a log file into the session
390
+
391
+ Workflow:
392
+ 1. capture session start --url http://localhost:3000
393
+ 2. Use CDP commands directly:
394
+ capture screenshot --url localhost --out $CAPTURE_DIR/shots/homepage.png --har $HAR_ID
395
+ capture exec "document.querySelector('.btn').click()" --url localhost --har $HAR_ID
396
+ capture a11y --url localhost --json > $CAPTURE_DIR/a11y/after-login.json
397
+ 3. capture session stop <session-id>
398
+ 4. capture session view <session-id>`);
399
+ }
400
+ }
401
+
402
+ // ============================================================================
403
+ // CLI
404
+ // ============================================================================
405
+
406
+ async function main(): Promise<void> {
407
+ const [command, ...args] = process.argv.slice(2);
408
+
409
+ switch (command) {
410
+ case "session":
411
+ return sessionMain(args);
412
+
413
+ case "log":
414
+ return logCommand(args);
415
+
416
+ // CDP commands — delegate to cdp.ts
417
+ case "detect":
418
+ case "exec":
419
+ case "open":
420
+ case "reset-tab":
421
+ case "screenshot":
422
+ case "click":
423
+ case "type":
424
+ case "a11y":
425
+ case "record":
426
+ case "navigate":
427
+ case "har":
428
+ case "network":
429
+ case "list":
430
+ return cdpMain();
431
+
432
+ default:
433
+ console.log(`Capture — session management and CDP browser automation
434
+
435
+ Session commands:
436
+ session start [--url <url>] Start a capture session
437
+ session stop <session-id> Finalize and bundle all artifacts
438
+ session list List active and stopped sessions
439
+ session view <id> [--filter ...] View bundle manifest
440
+ log <path> [--name label] Tail a log file into the session
441
+
442
+ CDP commands:
443
+ detect Detect CDP port
444
+ exec <code> Execute JS in a browser tab
445
+ exec --file <path> Execute JS from file
446
+ list List all browser tabs
447
+ open <url> Open URL in browser
448
+ reset-tab <url> Abandon stuck tab, open fresh one
449
+ screenshot Capture screenshot
450
+ click "name" Click element by accessible name
451
+ type "text" Type text into focused element
452
+ a11y Get accessibility tree
453
+ record Passive HAR recording
454
+ navigate <url> Navigate to URL and record HAR
455
+ network <offline|online> Toggle network (simulate disconnect)
456
+ har create|read|delete Manage HAR recordings
457
+
458
+ Options (CDP):
459
+ --port <port> Override CDP port
460
+ --target <tabId> Target tab by exact ID (preferred, parallel-safe)
461
+ --url <pattern> Target tab by URL pattern (fuzzy match)
462
+ --har <id> Append traffic to a HAR recording
463
+ --out <path> Output path (screenshot)
464
+ --json JSON output (a11y)
465
+ --interactive Interactive elements only (a11y)
466
+ --role <role> ARIA role filter (click)
467
+ --into "field" Target field by name (type)
468
+ --no-screenshot Skip auto-screenshot (click, type)
469
+ --filter-url <pat> Filter HAR by URL substring/regex (har read)
470
+ --filter-status <s> Filter HAR by status code, prefix, or range (har read)
471
+ --filter-method <m> Filter HAR by HTTP method (har read)
472
+ --limit <N> Limit HAR entries returned (har read)
473
+
474
+ Examples:
475
+ capture session start --url http://localhost:3000
476
+ capture session stop <session-id>
477
+ capture detect
478
+ capture list
479
+ capture exec "document.title" --url example
480
+ capture screenshot --url example --out /tmp/shot.png
481
+ capture a11y --url example --interactive`);
482
+ }
483
+ }
484
+
485
+ main().catch((err) => {
486
+ console.error(err.message);
487
+ process.exit(1);
488
+ });