@crouton-kit/capture 1.3.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/capture.ts CHANGED
@@ -22,382 +22,8 @@
22
22
 
23
23
  import * as fs from "fs";
24
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
25
  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
- }
26
+ import { sessionMain, logCommand } from "./session/commands.js";
401
27
 
402
28
  // ============================================================================
403
29
  // CLI
@@ -448,55 +74,73 @@ async function main(): Promise<void> {
448
74
  return cdpMain();
449
75
 
450
76
  default:
451
- console.log(`Capture — session management and CDP browser automation
77
+ console.log(`Capture — browser automation over CDP for validating UI features.
78
+
79
+ Most tasks start with a session. Once a session is active, every command
80
+ auto-fills --target (the tab) and --har (the recording) — don't thread
81
+ those flags yourself.
82
+
83
+ TYPICAL WORKFLOW
84
+
85
+ 1. Start a session (opens tab, starts HAR, marks it active):
86
+ capture session start --url http://localhost:3000
87
+
88
+ 2. Interact — no --target / --har needed:
89
+ capture a11y --interactive See what's on the page
90
+ capture click "Sign in" Click by accessible name (auto-screenshots)
91
+ capture type "hi@me.com" --into "Email"
92
+ capture screenshot Save current state
93
+ capture navigate https://... Navigate within the session
94
+ capture exec "document.title" Run JS (supports await)
95
+ capture har read --filter-url /api Inspect recorded traffic
96
+
97
+ 3. Bundle and inspect:
98
+ capture session stop <session-id>
99
+ capture session view <session-id>
100
+
101
+ SESSION COMMANDS
102
+
103
+ session start [--url <url>] Start a session
104
+ session stop <session-id> Finalize and bundle artifacts
105
+ session list List active and stopped sessions
106
+ session view <id> [--filter section] section = screenshots|har|a11y|logs
107
+ log <path> [--name label] Tail a log file into the active session
108
+
109
+ INTERACTION COMMANDS (work inside or outside a session)
110
+
111
+ a11y [--interactive] [--json] Accessibility tree (use this first to see elements)
112
+ click "name" [--role <role>] Click by accessible name
113
+ type "text" [--into "Field"] Type into focused element or named field
114
+ screenshot [--out <path>] [--full-page] Screenshot (viewport: desktop|desktop-wide|tablet|mobile)
115
+ exec <code> | exec --file <path> Evaluate JS; await is supported
116
+ navigate <url> [--settle <ms>] Navigate the current tab + record HAR
117
+
118
+ DIAGNOSTICS & ONE-OFFS (no session needed)
119
+
120
+ detect Find running CDP endpoints
121
+ list List open tabs across endpoints
122
+ open <url> [--new] Open a URL, return its tab id
123
+ reset-tab <url> Abandon a stuck tab, open fresh (updates session)
124
+ record [--duration <secs>] Passive HAR recording; parallel-safe capture
125
+ network <offline|online> Toggle connectivity for a tab
126
+ har create | read [id] | delete <id> Manage standalone HAR recordings
127
+
128
+ TARGETING (only when NOT in a session, or picking a parallel tab)
452
129
 
453
- Session commands:
454
- session start [--url <url>] Start a capture session
455
- session stop <session-id> Finalize and bundle all artifacts
456
- session list List active and stopped sessions
457
- session view <id> [--filter ...] View bundle manifest
458
- log <path> [--name label] Tail a log file into the session
130
+ --target <tabId> Exact id; a prefix of 8 chars is enough (preferred, parallel-safe)
131
+ --url <pattern> Fuzzy URL match against open tabs
132
+ NOTE: on \`session start\`, --url is a URL to OPEN, not a pattern.
459
133
 
460
- CDP commands:
461
- detect Detect CDP port
462
- exec <code> Execute JS in a browser tab
463
- exec --file <path> Execute JS from file
464
- list List all browser tabs
465
- open <url> Open URL in browser
466
- reset-tab <url> Abandon stuck tab, open fresh one
467
- screenshot Capture screenshot
468
- click "name" Click element by accessible name
469
- type "text" Type text into focused element
470
- a11y Get accessibility tree
471
- record Passive HAR recording
472
- navigate <url> Navigate to URL and record HAR
473
- network <offline|online> Toggle network (simulate disconnect)
474
- har create|read|delete Manage HAR recordings
134
+ HELP
475
135
 
476
- Options (CDP):
477
- --port <port> Override CDP port
478
- --target <tabId> Target tab by exact ID (preferred, parallel-safe)
479
- --url <pattern> Target tab by URL pattern (fuzzy match)
480
- --har <id> Append traffic to a HAR recording
481
- --out <path> Output path (screenshot)
482
- --json JSON output (a11y)
483
- --interactive Interactive elements only (a11y)
484
- --role <role> ARIA role filter (click)
485
- --into "field" Target field by name (type)
486
- --no-screenshot Skip auto-screenshot (click, type)
487
- --filter-url <pat> Filter HAR by URL substring/regex (har read)
488
- --filter-status <s> Filter HAR by status code, prefix, or range (har read)
489
- --filter-method <m> Filter HAR by HTTP method (har read)
490
- --limit <N> Limit HAR entries returned (har read)
136
+ capture <command> --help Per-command usage, e.g. capture click --help, capture har --help
137
+ capture --version Print version
491
138
 
492
- Examples:
493
- capture session start --url http://localhost:3000
494
- capture session stop <session-id>
495
- capture detect
496
- capture list
497
- capture exec "document.title" --url example
498
- capture screenshot --url example --out /tmp/shot.png
499
- capture a11y --url example --interactive`);
139
+ PREREQ — a browser with CDP must be running:
140
+ Arc enabled by default
141
+ Chrome / Chromium --remote-debugging-port=9222
142
+ Electron apps CDP exposed automatically
143
+ Verify with: capture detect`);
500
144
  }
501
145
  }
502
146
 
@@ -0,0 +1,104 @@
1
+ import { type CDPClient } from './client.js';
2
+
3
+ export const INTERACTIVE_ROLES = new Set([
4
+ 'button',
5
+ 'link',
6
+ 'textbox',
7
+ 'searchbox',
8
+ 'combobox',
9
+ 'checkbox',
10
+ 'radio',
11
+ 'menuitem',
12
+ 'tab',
13
+ 'switch',
14
+ 'slider',
15
+ 'spinbutton',
16
+ 'option',
17
+ 'menuitemcheckbox',
18
+ 'menuitemradio',
19
+ ]);
20
+
21
+ export function flattenA11yTree(
22
+ nodes: A11yNode[],
23
+ opts: { interactive?: boolean } = {},
24
+ depth = 0,
25
+ ): string[] {
26
+ const lines: string[] = [];
27
+
28
+ for (const node of nodes) {
29
+ // Filter to interactive roles if requested
30
+ if (opts.interactive && !INTERACTIVE_ROLES.has(node.role)) {
31
+ lines.push(...flattenA11yTree(node.children, opts, depth));
32
+ continue;
33
+ }
34
+
35
+ // Skip nodes without meaningful role or name
36
+ if (!node.role || node.role === 'StaticText') {
37
+ lines.push(...flattenA11yTree(node.children, opts, depth));
38
+ continue;
39
+ }
40
+
41
+ const indent = ' '.repeat(depth);
42
+ const nameStr = node.name ? ` "${node.name}"` : '';
43
+ lines.push(`${indent}${node.role}${nameStr}`);
44
+
45
+ // Recurse
46
+ lines.push(...flattenA11yTree(node.children, opts, depth + 1));
47
+ }
48
+
49
+ return lines;
50
+ }
51
+
52
+ interface AXNode {
53
+ nodeId: string;
54
+ role?: { value: string };
55
+ name?: { value: string };
56
+ childIds?: string[];
57
+ properties?: Array<{ name: string; value: { value: unknown } }>;
58
+ }
59
+
60
+ export interface A11yNode {
61
+ role: string;
62
+ name: string;
63
+ children: A11yNode[];
64
+ }
65
+
66
+ export async function getAccessibilityTree(
67
+ client: CDPClient,
68
+ ): Promise<A11yNode[]> {
69
+ await client.send('Accessibility.enable');
70
+ const result = (await client.send('Accessibility.getFullAXTree')) as {
71
+ nodes: AXNode[];
72
+ };
73
+ await client.send('Accessibility.disable');
74
+
75
+ const nodeMap = new Map<string, AXNode>();
76
+ for (const node of result.nodes) {
77
+ nodeMap.set(node.nodeId, node);
78
+ }
79
+
80
+ const SKIP_ROLES = new Set(['none', 'generic', 'InlineTextBox']);
81
+
82
+ function buildNode(node: AXNode): A11yNode[] {
83
+ const role = node.role?.value ?? '';
84
+ const childNodes = (node.childIds ?? []).flatMap((id) => {
85
+ const child = nodeMap.get(id);
86
+ return child ? buildNode(child) : [];
87
+ });
88
+
89
+ if (SKIP_ROLES.has(role)) {
90
+ return childNodes;
91
+ }
92
+
93
+ return [
94
+ {
95
+ role,
96
+ name: node.name?.value ?? '',
97
+ children: childNodes,
98
+ },
99
+ ];
100
+ }
101
+
102
+ const root = result.nodes[0];
103
+ return root ? buildNode(root) : [];
104
+ }