@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/bin/capture +1767 -1512
- package/package.json +1 -1
- package/src/capture.ts +64 -420
- package/src/cdp/a11y.ts +104 -0
- package/src/cdp/args.ts +101 -0
- package/src/cdp/client.ts +97 -0
- package/src/cdp/commands/exec.ts +84 -0
- package/src/cdp/commands/har.ts +128 -0
- package/src/cdp/commands/tabs.ts +189 -0
- package/src/cdp/commands/traffic.ts +121 -0
- package/src/cdp/commands/ui.ts +172 -0
- package/src/cdp/connection.ts +108 -0
- package/src/cdp/console-recorder.ts +80 -0
- package/src/cdp/detect.ts +150 -0
- package/src/cdp/dispatch.ts +30 -0
- package/src/cdp/execute.ts +117 -0
- package/src/cdp/har-output.ts +47 -0
- package/src/cdp/har-recorder.ts +147 -0
- package/src/cdp/record.ts +221 -0
- package/src/cdp/screenshot.ts +100 -0
- package/src/cdp/tab-lock.ts +132 -0
- package/src/cdp/targets.ts +112 -0
- package/src/cdp/types.ts +36 -0
- package/src/cdp/usage.ts +62 -0
- package/src/cdp.ts +17 -2333
- package/src/session/commands.ts +385 -0
package/package.json
CHANGED
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 —
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
--
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
capture
|
|
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
|
|
package/src/cdp/a11y.ts
ADDED
|
@@ -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
|
+
}
|