browser-pilot 0.0.4 → 0.0.6
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/README.md +39 -0
- package/dist/actions.cjs +2 -1
- package/dist/actions.d.cts +2 -2
- package/dist/actions.d.ts +2 -2
- package/dist/actions.mjs +1 -1
- package/dist/browser.cjs +26 -4
- package/dist/browser.d.cts +2 -2
- package/dist/browser.d.ts +2 -2
- package/dist/browser.mjs +2 -2
- package/dist/{chunk-YEHK2XY3.mjs → chunk-6RB3GKQP.mjs} +2 -1
- package/dist/{chunk-CWSTSVWO.mjs → chunk-PCNEJAJ7.mjs} +25 -4
- package/dist/cli.cjs +948 -88
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +842 -4
- package/dist/index.cjs +26 -4
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{types-BJv2dzu0.d.ts → types-CbdmaocU.d.ts} +9 -0
- package/dist/{types-C6m0bT04.d.cts → types-TVlTA7nH.d.cts} +9 -0
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -214,6 +214,142 @@ async function actionsCommand() {
|
|
|
214
214
|
console.log(ACTIONS_HELP);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// src/cli/session.ts
|
|
218
|
+
var import_node_os = require("os");
|
|
219
|
+
var import_node_path = require("path");
|
|
220
|
+
var SESSION_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".browser-pilot", "sessions");
|
|
221
|
+
async function ensureSessionDir() {
|
|
222
|
+
const fs = await import("fs/promises");
|
|
223
|
+
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
async function saveSession(session) {
|
|
226
|
+
await ensureSessionDir();
|
|
227
|
+
const fs = await import("fs/promises");
|
|
228
|
+
const filePath = (0, import_node_path.join)(SESSION_DIR, `${session.id}.json`);
|
|
229
|
+
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
230
|
+
}
|
|
231
|
+
async function loadSession(id) {
|
|
232
|
+
const fs = await import("fs/promises");
|
|
233
|
+
const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
|
|
234
|
+
try {
|
|
235
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
236
|
+
return JSON.parse(content);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.code === "ENOENT") {
|
|
239
|
+
throw new Error(`Session not found: ${id}`);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function updateSession(id, updates) {
|
|
245
|
+
const session = await loadSession(id);
|
|
246
|
+
const mergedMetadata = updates.metadata !== void 0 ? { ...session.metadata ?? {}, ...updates.metadata ?? {} } : session.metadata;
|
|
247
|
+
const updated = {
|
|
248
|
+
...session,
|
|
249
|
+
...updates,
|
|
250
|
+
metadata: mergedMetadata,
|
|
251
|
+
lastActivity: (/* @__PURE__ */ new Date()).toISOString()
|
|
252
|
+
};
|
|
253
|
+
await saveSession(updated);
|
|
254
|
+
return updated;
|
|
255
|
+
}
|
|
256
|
+
async function deleteSession(id) {
|
|
257
|
+
const fs = await import("fs/promises");
|
|
258
|
+
const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
|
|
259
|
+
try {
|
|
260
|
+
await fs.unlink(filePath);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error.code !== "ENOENT") {
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function listSessions() {
|
|
268
|
+
await ensureSessionDir();
|
|
269
|
+
const fs = await import("fs/promises");
|
|
270
|
+
try {
|
|
271
|
+
const files = await fs.readdir(SESSION_DIR);
|
|
272
|
+
const sessions = [];
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
if (file.endsWith(".json")) {
|
|
275
|
+
try {
|
|
276
|
+
const content = await fs.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
|
|
277
|
+
sessions.push(JSON.parse(content));
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return sessions.sort(
|
|
283
|
+
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
|
284
|
+
);
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function generateSessionId() {
|
|
290
|
+
const timestamp = Date.now().toString(36);
|
|
291
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
292
|
+
return `${timestamp}-${random}`;
|
|
293
|
+
}
|
|
294
|
+
async function getDefaultSession() {
|
|
295
|
+
const sessions = await listSessions();
|
|
296
|
+
return sessions[0] ?? null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/cli/commands/clean.ts
|
|
300
|
+
function parseCleanArgs(args) {
|
|
301
|
+
const options = {};
|
|
302
|
+
for (let i = 0; i < args.length; i++) {
|
|
303
|
+
const arg = args[i];
|
|
304
|
+
if (arg === "--max-age") {
|
|
305
|
+
const value = args[++i];
|
|
306
|
+
options.maxAge = parseInt(value ?? "24", 10);
|
|
307
|
+
} else if (arg === "--dry-run") {
|
|
308
|
+
options.dryRun = true;
|
|
309
|
+
} else if (arg === "--all") {
|
|
310
|
+
options.all = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return options;
|
|
314
|
+
}
|
|
315
|
+
async function cleanCommand(args, globalOptions) {
|
|
316
|
+
const options = parseCleanArgs(args);
|
|
317
|
+
const maxAgeMs = (options.maxAge ?? 24) * 60 * 60 * 1e3;
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
const sessions = await listSessions();
|
|
320
|
+
const stale = sessions.filter((s) => {
|
|
321
|
+
if (options.all) return true;
|
|
322
|
+
const age = now - new Date(s.lastActivity).getTime();
|
|
323
|
+
return age > maxAgeMs;
|
|
324
|
+
});
|
|
325
|
+
if (stale.length === 0) {
|
|
326
|
+
output({ message: "No stale sessions found", cleaned: 0 }, globalOptions.output);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (options.dryRun) {
|
|
330
|
+
output(
|
|
331
|
+
{
|
|
332
|
+
message: `Would clean ${stale.length} session(s)`,
|
|
333
|
+
sessions: stale.map((s) => s.id),
|
|
334
|
+
dryRun: true
|
|
335
|
+
},
|
|
336
|
+
globalOptions.output
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
for (const session of stale) {
|
|
341
|
+
await deleteSession(session.id);
|
|
342
|
+
}
|
|
343
|
+
output(
|
|
344
|
+
{
|
|
345
|
+
message: `Cleaned ${stale.length} session(s)`,
|
|
346
|
+
cleaned: stale.length,
|
|
347
|
+
sessions: stale.map((s) => s.id)
|
|
348
|
+
},
|
|
349
|
+
globalOptions.output
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
217
353
|
// src/actions/executor.ts
|
|
218
354
|
var DEFAULT_TIMEOUT = 3e4;
|
|
219
355
|
var BatchExecutor = class {
|
|
@@ -299,7 +435,8 @@ var BatchExecutor = class {
|
|
|
299
435
|
await this.page.fill(step.selector, step.value, {
|
|
300
436
|
timeout,
|
|
301
437
|
optional,
|
|
302
|
-
clear: step.clear ?? true
|
|
438
|
+
clear: step.clear ?? true,
|
|
439
|
+
blur: step.blur
|
|
303
440
|
});
|
|
304
441
|
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
305
442
|
}
|
|
@@ -1327,6 +1464,13 @@ var Page = class {
|
|
|
1327
1464
|
this.cdp = cdp;
|
|
1328
1465
|
this.batchExecutor = new BatchExecutor(this);
|
|
1329
1466
|
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Get the underlying CDP client for advanced operations.
|
|
1469
|
+
* Use with caution - prefer high-level Page methods when possible.
|
|
1470
|
+
*/
|
|
1471
|
+
get cdpClient() {
|
|
1472
|
+
return this.cdp;
|
|
1473
|
+
}
|
|
1330
1474
|
/**
|
|
1331
1475
|
* Initialize the page (enable required CDP domains)
|
|
1332
1476
|
*/
|
|
@@ -1477,7 +1621,7 @@ var Page = class {
|
|
|
1477
1621
|
* Fill an input field (clears first by default)
|
|
1478
1622
|
*/
|
|
1479
1623
|
async fill(selector, value, options = {}) {
|
|
1480
|
-
const { clear = true } = options;
|
|
1624
|
+
const { clear = true, blur = false } = options;
|
|
1481
1625
|
return this.withStaleNodeRetry(async () => {
|
|
1482
1626
|
const element = await this.findElement(selector, options);
|
|
1483
1627
|
if (!element) {
|
|
@@ -1491,7 +1635,11 @@ var Page = class {
|
|
|
1491
1635
|
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
1492
1636
|
if (el) {
|
|
1493
1637
|
el.value = '';
|
|
1494
|
-
el.dispatchEvent(new
|
|
1638
|
+
el.dispatchEvent(new InputEvent('input', {
|
|
1639
|
+
bubbles: true,
|
|
1640
|
+
cancelable: true,
|
|
1641
|
+
inputType: 'deleteContent'
|
|
1642
|
+
}));
|
|
1495
1643
|
}
|
|
1496
1644
|
})()`
|
|
1497
1645
|
);
|
|
@@ -1501,11 +1649,21 @@ var Page = class {
|
|
|
1501
1649
|
`(() => {
|
|
1502
1650
|
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
1503
1651
|
if (el) {
|
|
1504
|
-
el.dispatchEvent(new
|
|
1652
|
+
el.dispatchEvent(new InputEvent('input', {
|
|
1653
|
+
bubbles: true,
|
|
1654
|
+
cancelable: true,
|
|
1655
|
+
inputType: 'insertText',
|
|
1656
|
+
data: ${JSON.stringify(value)}
|
|
1657
|
+
}));
|
|
1505
1658
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1506
1659
|
}
|
|
1507
1660
|
})()`
|
|
1508
1661
|
);
|
|
1662
|
+
if (blur) {
|
|
1663
|
+
await this.evaluateInFrame(
|
|
1664
|
+
`document.querySelector(${JSON.stringify(element.selector)})?.blur()`
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1509
1667
|
return true;
|
|
1510
1668
|
});
|
|
1511
1669
|
}
|
|
@@ -2974,88 +3132,6 @@ function connect(options) {
|
|
|
2974
3132
|
return Browser.connect(options);
|
|
2975
3133
|
}
|
|
2976
3134
|
|
|
2977
|
-
// src/cli/session.ts
|
|
2978
|
-
var import_node_os = require("os");
|
|
2979
|
-
var import_node_path = require("path");
|
|
2980
|
-
var SESSION_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".browser-pilot", "sessions");
|
|
2981
|
-
async function ensureSessionDir() {
|
|
2982
|
-
const fs = await import("fs/promises");
|
|
2983
|
-
await fs.mkdir(SESSION_DIR, { recursive: true });
|
|
2984
|
-
}
|
|
2985
|
-
async function saveSession(session) {
|
|
2986
|
-
await ensureSessionDir();
|
|
2987
|
-
const fs = await import("fs/promises");
|
|
2988
|
-
const filePath = (0, import_node_path.join)(SESSION_DIR, `${session.id}.json`);
|
|
2989
|
-
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
2990
|
-
}
|
|
2991
|
-
async function loadSession(id) {
|
|
2992
|
-
const fs = await import("fs/promises");
|
|
2993
|
-
const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
|
|
2994
|
-
try {
|
|
2995
|
-
const content = await fs.readFile(filePath, "utf-8");
|
|
2996
|
-
return JSON.parse(content);
|
|
2997
|
-
} catch (error) {
|
|
2998
|
-
if (error.code === "ENOENT") {
|
|
2999
|
-
throw new Error(`Session not found: ${id}`);
|
|
3000
|
-
}
|
|
3001
|
-
throw error;
|
|
3002
|
-
}
|
|
3003
|
-
}
|
|
3004
|
-
async function updateSession(id, updates) {
|
|
3005
|
-
const session = await loadSession(id);
|
|
3006
|
-
const mergedMetadata = updates.metadata !== void 0 ? { ...session.metadata ?? {}, ...updates.metadata ?? {} } : session.metadata;
|
|
3007
|
-
const updated = {
|
|
3008
|
-
...session,
|
|
3009
|
-
...updates,
|
|
3010
|
-
metadata: mergedMetadata,
|
|
3011
|
-
lastActivity: (/* @__PURE__ */ new Date()).toISOString()
|
|
3012
|
-
};
|
|
3013
|
-
await saveSession(updated);
|
|
3014
|
-
return updated;
|
|
3015
|
-
}
|
|
3016
|
-
async function deleteSession(id) {
|
|
3017
|
-
const fs = await import("fs/promises");
|
|
3018
|
-
const filePath = (0, import_node_path.join)(SESSION_DIR, `${id}.json`);
|
|
3019
|
-
try {
|
|
3020
|
-
await fs.unlink(filePath);
|
|
3021
|
-
} catch (error) {
|
|
3022
|
-
if (error.code !== "ENOENT") {
|
|
3023
|
-
throw error;
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
}
|
|
3027
|
-
async function listSessions() {
|
|
3028
|
-
await ensureSessionDir();
|
|
3029
|
-
const fs = await import("fs/promises");
|
|
3030
|
-
try {
|
|
3031
|
-
const files = await fs.readdir(SESSION_DIR);
|
|
3032
|
-
const sessions = [];
|
|
3033
|
-
for (const file of files) {
|
|
3034
|
-
if (file.endsWith(".json")) {
|
|
3035
|
-
try {
|
|
3036
|
-
const content = await fs.readFile((0, import_node_path.join)(SESSION_DIR, file), "utf-8");
|
|
3037
|
-
sessions.push(JSON.parse(content));
|
|
3038
|
-
} catch {
|
|
3039
|
-
}
|
|
3040
|
-
}
|
|
3041
|
-
}
|
|
3042
|
-
return sessions.sort(
|
|
3043
|
-
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
|
3044
|
-
);
|
|
3045
|
-
} catch {
|
|
3046
|
-
return [];
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
function generateSessionId() {
|
|
3050
|
-
const timestamp = Date.now().toString(36);
|
|
3051
|
-
const random = Math.random().toString(36).slice(2, 8);
|
|
3052
|
-
return `${timestamp}-${random}`;
|
|
3053
|
-
}
|
|
3054
|
-
async function getDefaultSession() {
|
|
3055
|
-
const sessions = await listSessions();
|
|
3056
|
-
return sessions[0] ?? null;
|
|
3057
|
-
}
|
|
3058
|
-
|
|
3059
3135
|
// src/cli/commands/close.ts
|
|
3060
3136
|
async function closeCommand(args, globalOptions) {
|
|
3061
3137
|
let session;
|
|
@@ -3174,6 +3250,17 @@ async function connectCommand(args, globalOptions) {
|
|
|
3174
3250
|
}
|
|
3175
3251
|
|
|
3176
3252
|
// src/cli/commands/exec.ts
|
|
3253
|
+
async function validateSession(session) {
|
|
3254
|
+
try {
|
|
3255
|
+
const wsUrl = new URL(session.wsUrl);
|
|
3256
|
+
const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
|
|
3257
|
+
const httpUrl = `${protocol}//${wsUrl.host}/json/version`;
|
|
3258
|
+
const response = await fetch(httpUrl, { signal: AbortSignal.timeout(3e3) });
|
|
3259
|
+
return response.ok;
|
|
3260
|
+
} catch {
|
|
3261
|
+
return false;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3177
3264
|
function parseExecArgs(args) {
|
|
3178
3265
|
const options = {};
|
|
3179
3266
|
let actionsJson;
|
|
@@ -3218,6 +3305,14 @@ Run 'bp actions' for complete action reference.`
|
|
|
3218
3305
|
throw new Error('No session found. Run "bp connect" first.');
|
|
3219
3306
|
}
|
|
3220
3307
|
}
|
|
3308
|
+
const isValid = await validateSession(session);
|
|
3309
|
+
if (!isValid) {
|
|
3310
|
+
await deleteSession(session.id);
|
|
3311
|
+
throw new Error(
|
|
3312
|
+
`Session "${session.id}" is no longer valid (browser may have closed).
|
|
3313
|
+
Session file has been cleaned up. Run "bp connect" to create a new session.`
|
|
3314
|
+
);
|
|
3315
|
+
}
|
|
3221
3316
|
const browser = await connect({
|
|
3222
3317
|
provider: session.provider,
|
|
3223
3318
|
wsUrl: session.wsUrl,
|
|
@@ -3372,12 +3467,760 @@ COMMON ACTIONS
|
|
|
3372
3467
|
snapshot {"action":"snapshot"}
|
|
3373
3468
|
screenshot {"action":"screenshot"}
|
|
3374
3469
|
|
|
3470
|
+
RECORDING (FOR HUMANS)
|
|
3471
|
+
Want to create automations by demonstrating instead of coding?
|
|
3472
|
+
Use 'bp record' to capture your browser interactions as replayable JSON:
|
|
3473
|
+
|
|
3474
|
+
bp record # Record from local Chrome
|
|
3475
|
+
bp exec --file login.json # Replay the recording
|
|
3476
|
+
|
|
3477
|
+
Great for creating initial automation scripts that AI agents can refine.
|
|
3478
|
+
|
|
3375
3479
|
Run 'bp actions' for the complete action reference.
|
|
3376
3480
|
`;
|
|
3377
3481
|
async function quickstartCommand() {
|
|
3378
3482
|
console.log(QUICKSTART);
|
|
3379
3483
|
}
|
|
3380
3484
|
|
|
3485
|
+
// src/recording/aggregator.ts
|
|
3486
|
+
var INPUT_DEBOUNCE_MS = 300;
|
|
3487
|
+
var NAVIGATION_DEBOUNCE_MS = 500;
|
|
3488
|
+
function selectBestSelectors(candidates) {
|
|
3489
|
+
const qualityOrder = {
|
|
3490
|
+
"stable-attr": 0,
|
|
3491
|
+
id: 1,
|
|
3492
|
+
"css-path": 2
|
|
3493
|
+
};
|
|
3494
|
+
const sorted = [...candidates].sort((a, b) => {
|
|
3495
|
+
const aOrder = qualityOrder[a.quality] ?? 3;
|
|
3496
|
+
const bOrder = qualityOrder[b.quality] ?? 3;
|
|
3497
|
+
return aOrder - bOrder;
|
|
3498
|
+
});
|
|
3499
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3500
|
+
const result = [];
|
|
3501
|
+
for (const candidate of sorted) {
|
|
3502
|
+
if (!seen.has(candidate.selector)) {
|
|
3503
|
+
seen.add(candidate.selector);
|
|
3504
|
+
result.push(candidate.selector);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
return result;
|
|
3508
|
+
}
|
|
3509
|
+
function debounceInputEvents(events) {
|
|
3510
|
+
const result = [];
|
|
3511
|
+
for (let i = 0; i < events.length; i++) {
|
|
3512
|
+
const event = events[i];
|
|
3513
|
+
if (event.kind !== "input") {
|
|
3514
|
+
result.push(event);
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
const primarySelector = event.selectors[0]?.selector;
|
|
3518
|
+
if (!primarySelector) {
|
|
3519
|
+
result.push(event);
|
|
3520
|
+
continue;
|
|
3521
|
+
}
|
|
3522
|
+
let finalEvent = event;
|
|
3523
|
+
let j = i + 1;
|
|
3524
|
+
while (j < events.length) {
|
|
3525
|
+
const nextEvent = events[j];
|
|
3526
|
+
if (nextEvent.timestamp - finalEvent.timestamp > INPUT_DEBOUNCE_MS) {
|
|
3527
|
+
break;
|
|
3528
|
+
}
|
|
3529
|
+
if (nextEvent.kind !== "input") {
|
|
3530
|
+
break;
|
|
3531
|
+
}
|
|
3532
|
+
const nextPrimarySelector = nextEvent.selectors[0]?.selector;
|
|
3533
|
+
if (nextPrimarySelector !== primarySelector) {
|
|
3534
|
+
break;
|
|
3535
|
+
}
|
|
3536
|
+
finalEvent = nextEvent;
|
|
3537
|
+
j++;
|
|
3538
|
+
}
|
|
3539
|
+
i = j - 1;
|
|
3540
|
+
result.push(finalEvent);
|
|
3541
|
+
}
|
|
3542
|
+
return result;
|
|
3543
|
+
}
|
|
3544
|
+
function debounceNavigationEvents(events) {
|
|
3545
|
+
const result = [];
|
|
3546
|
+
for (let i = 0; i < events.length; i++) {
|
|
3547
|
+
const event = events[i];
|
|
3548
|
+
if (event.kind !== "navigation") {
|
|
3549
|
+
result.push(event);
|
|
3550
|
+
continue;
|
|
3551
|
+
}
|
|
3552
|
+
let finalEvent = event;
|
|
3553
|
+
let j = i + 1;
|
|
3554
|
+
while (j < events.length) {
|
|
3555
|
+
const nextEvent = events[j];
|
|
3556
|
+
if (nextEvent.timestamp - finalEvent.timestamp > NAVIGATION_DEBOUNCE_MS) {
|
|
3557
|
+
break;
|
|
3558
|
+
}
|
|
3559
|
+
if (nextEvent.kind !== "navigation") {
|
|
3560
|
+
break;
|
|
3561
|
+
}
|
|
3562
|
+
finalEvent = nextEvent;
|
|
3563
|
+
j++;
|
|
3564
|
+
}
|
|
3565
|
+
i = j - 1;
|
|
3566
|
+
result.push(finalEvent);
|
|
3567
|
+
}
|
|
3568
|
+
return result;
|
|
3569
|
+
}
|
|
3570
|
+
function insertNavigationSteps(events, startUrl) {
|
|
3571
|
+
const result = [];
|
|
3572
|
+
let lastUrl = startUrl || null;
|
|
3573
|
+
for (const event of events) {
|
|
3574
|
+
if (lastUrl !== null && event.url !== lastUrl) {
|
|
3575
|
+
result.push({
|
|
3576
|
+
kind: "navigation",
|
|
3577
|
+
timestamp: event.timestamp,
|
|
3578
|
+
url: event.url,
|
|
3579
|
+
selectors: []
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
result.push(event);
|
|
3583
|
+
lastUrl = event.url;
|
|
3584
|
+
}
|
|
3585
|
+
return result;
|
|
3586
|
+
}
|
|
3587
|
+
function eventToStep(event) {
|
|
3588
|
+
const selectors = selectBestSelectors(event.selectors);
|
|
3589
|
+
switch (event.kind) {
|
|
3590
|
+
case "click":
|
|
3591
|
+
case "dblclick":
|
|
3592
|
+
if (selectors.length === 0) return null;
|
|
3593
|
+
return {
|
|
3594
|
+
action: "click",
|
|
3595
|
+
selector: selectors.length === 1 ? selectors[0] : selectors
|
|
3596
|
+
};
|
|
3597
|
+
case "input":
|
|
3598
|
+
if (selectors.length === 0) return null;
|
|
3599
|
+
return {
|
|
3600
|
+
action: "fill",
|
|
3601
|
+
selector: selectors.length === 1 ? selectors[0] : selectors,
|
|
3602
|
+
value: event.value ?? ""
|
|
3603
|
+
};
|
|
3604
|
+
case "change": {
|
|
3605
|
+
if (selectors.length === 0) return null;
|
|
3606
|
+
const element = event.element;
|
|
3607
|
+
const tag = element?.tag;
|
|
3608
|
+
const type = element?.type?.toLowerCase();
|
|
3609
|
+
if (tag === "select") {
|
|
3610
|
+
return {
|
|
3611
|
+
action: "select",
|
|
3612
|
+
selector: selectors.length === 1 ? selectors[0] : selectors,
|
|
3613
|
+
value: event.value ?? ""
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
if (type === "checkbox" || type === "radio") {
|
|
3617
|
+
return {
|
|
3618
|
+
action: event.checked ? "check" : "uncheck",
|
|
3619
|
+
selector: selectors.length === 1 ? selectors[0] : selectors
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
return {
|
|
3623
|
+
action: "fill",
|
|
3624
|
+
selector: selectors.length === 1 ? selectors[0] : selectors,
|
|
3625
|
+
value: event.value ?? ""
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
case "keydown":
|
|
3629
|
+
if (event.key === "Enter") {
|
|
3630
|
+
if (selectors.length === 0) return null;
|
|
3631
|
+
return {
|
|
3632
|
+
action: "submit",
|
|
3633
|
+
selector: selectors.length === 1 ? selectors[0] : selectors,
|
|
3634
|
+
method: "enter"
|
|
3635
|
+
};
|
|
3636
|
+
}
|
|
3637
|
+
return null;
|
|
3638
|
+
case "submit":
|
|
3639
|
+
if (selectors.length === 0) return null;
|
|
3640
|
+
return {
|
|
3641
|
+
action: "submit",
|
|
3642
|
+
selector: selectors.length === 1 ? selectors[0] : selectors
|
|
3643
|
+
};
|
|
3644
|
+
case "navigation":
|
|
3645
|
+
return {
|
|
3646
|
+
action: "goto",
|
|
3647
|
+
url: event.url
|
|
3648
|
+
};
|
|
3649
|
+
default:
|
|
3650
|
+
return null;
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
function deduplicateSteps(steps) {
|
|
3654
|
+
const result = [];
|
|
3655
|
+
for (let i = 0; i < steps.length; i++) {
|
|
3656
|
+
const step = steps[i];
|
|
3657
|
+
const prevStep = result[result.length - 1];
|
|
3658
|
+
if (step.action === "submit" && prevStep?.action === "submit" && JSON.stringify(step.selector) === JSON.stringify(prevStep.selector)) {
|
|
3659
|
+
continue;
|
|
3660
|
+
}
|
|
3661
|
+
result.push(step);
|
|
3662
|
+
}
|
|
3663
|
+
return result;
|
|
3664
|
+
}
|
|
3665
|
+
function aggregateEvents(events, startUrl) {
|
|
3666
|
+
if (events.length === 0) return [];
|
|
3667
|
+
let processed = insertNavigationSteps(events, startUrl);
|
|
3668
|
+
processed = debounceNavigationEvents(processed);
|
|
3669
|
+
processed = debounceInputEvents(processed);
|
|
3670
|
+
const steps = [];
|
|
3671
|
+
for (const event of processed) {
|
|
3672
|
+
const step = eventToStep(event);
|
|
3673
|
+
if (step) {
|
|
3674
|
+
steps.push(step);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
return deduplicateSteps(steps);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
// src/recording/script.ts
|
|
3681
|
+
var RECORDER_BINDING_NAME = "__recorder";
|
|
3682
|
+
var RECORDER_SCRIPT = `(function() {
|
|
3683
|
+
// Guard against multiple installations
|
|
3684
|
+
if (window.__recorderInstalled) return;
|
|
3685
|
+
window.__recorderInstalled = true;
|
|
3686
|
+
|
|
3687
|
+
const BINDING_NAME = '__recorder';
|
|
3688
|
+
|
|
3689
|
+
// Safe JSON stringify
|
|
3690
|
+
function safeJson(obj) {
|
|
3691
|
+
try {
|
|
3692
|
+
return JSON.stringify(obj);
|
|
3693
|
+
} catch (e) {
|
|
3694
|
+
return JSON.stringify({ error: 'unserializable' });
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
// Send event to CDP client via binding
|
|
3699
|
+
function sendEvent(payload) {
|
|
3700
|
+
try {
|
|
3701
|
+
if (typeof window[BINDING_NAME] === 'function') {
|
|
3702
|
+
window[BINDING_NAME](safeJson(payload));
|
|
3703
|
+
}
|
|
3704
|
+
} catch (e) {
|
|
3705
|
+
// Binding not ready, ignore
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
// CSS escape for identifiers
|
|
3710
|
+
function cssEscape(str) {
|
|
3711
|
+
return String(str).replace(/([\\[\\]#.:>+~=|^$*!"'(){}])/g, '\\\\$1');
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
// Check if selector is unique in document
|
|
3715
|
+
function isUnique(selector, root) {
|
|
3716
|
+
try {
|
|
3717
|
+
return (root || document).querySelectorAll(selector).length === 1;
|
|
3718
|
+
} catch (e) {
|
|
3719
|
+
return false;
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// Get stable attribute selector (data-testid, aria-label, name, etc.)
|
|
3724
|
+
function getStableAttrSelector(el) {
|
|
3725
|
+
if (!el || el.nodeType !== 1) return null;
|
|
3726
|
+
const attrs = ['data-testid', 'data-test', 'data-qa', 'aria-label', 'name'];
|
|
3727
|
+
for (const attr of attrs) {
|
|
3728
|
+
const val = el.getAttribute(attr);
|
|
3729
|
+
if (val && val.length <= 200) {
|
|
3730
|
+
const escaped = val.replace(/"/g, '\\\\"');
|
|
3731
|
+
return '[' + attr + '="' + escaped + '"]';
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
return null;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
// Get ID selector
|
|
3738
|
+
function getIdSelector(el) {
|
|
3739
|
+
if (!el || !el.id || el.id.length > 100) return null;
|
|
3740
|
+
// Skip dynamic-looking IDs
|
|
3741
|
+
if (/^[0-9]|^:/.test(el.id)) return null;
|
|
3742
|
+
return '#' + cssEscape(el.id);
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
// Build CSS path for element
|
|
3746
|
+
function buildCssPath(el) {
|
|
3747
|
+
if (!el || el.nodeType !== 1) return null;
|
|
3748
|
+
const parts = [];
|
|
3749
|
+
let cur = el;
|
|
3750
|
+
|
|
3751
|
+
for (let depth = 0; cur && cur !== document.body && depth < 8; depth++) {
|
|
3752
|
+
let part = cur.tagName.toLowerCase();
|
|
3753
|
+
|
|
3754
|
+
// If ID exists and looks stable, use it and stop
|
|
3755
|
+
if (cur.id && !/^[0-9]|^:/.test(cur.id) && cur.id.length <= 50) {
|
|
3756
|
+
part = '#' + cssEscape(cur.id);
|
|
3757
|
+
parts.unshift(part);
|
|
3758
|
+
break;
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
// Add stable classes (skip dynamic ones)
|
|
3762
|
+
const classes = Array.from(cur.classList || [])
|
|
3763
|
+
.filter(c => c.length < 40 && !/^css-|^_|^[0-9]/.test(c))
|
|
3764
|
+
.slice(0, 2);
|
|
3765
|
+
if (classes.length) {
|
|
3766
|
+
part += '.' + classes.map(cssEscape).join('.');
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
// Add position if siblings have same tag
|
|
3770
|
+
const parent = cur.parentElement;
|
|
3771
|
+
if (parent) {
|
|
3772
|
+
const sameTag = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
|
|
3773
|
+
if (sameTag.length > 1) {
|
|
3774
|
+
const idx = sameTag.indexOf(cur) + 1;
|
|
3775
|
+
part += ':nth-of-type(' + idx + ')';
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
parts.unshift(part);
|
|
3780
|
+
cur = cur.parentElement;
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
return parts.join(' > ');
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
// Generate selector candidates ordered by quality
|
|
3787
|
+
function getSelectorCandidates(el) {
|
|
3788
|
+
const candidates = [];
|
|
3789
|
+
|
|
3790
|
+
// 1. Stable attributes (highest quality)
|
|
3791
|
+
const stableAttr = getStableAttrSelector(el);
|
|
3792
|
+
if (stableAttr) {
|
|
3793
|
+
candidates.push({ selector: stableAttr, quality: 'stable-attr' });
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
// 2. ID selector
|
|
3797
|
+
const idSel = getIdSelector(el);
|
|
3798
|
+
if (idSel) {
|
|
3799
|
+
candidates.push({ selector: idSel, quality: 'id' });
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// 3. CSS path (fallback)
|
|
3803
|
+
const cssPath = buildCssPath(el);
|
|
3804
|
+
if (cssPath) {
|
|
3805
|
+
candidates.push({ selector: cssPath, quality: 'css-path' });
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
return candidates;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
// Get element summary for debugging
|
|
3812
|
+
function getElementSummary(el) {
|
|
3813
|
+
if (!el || el.nodeType !== 1) return null;
|
|
3814
|
+
const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
|
|
3815
|
+
return {
|
|
3816
|
+
tag: el.tagName.toLowerCase(),
|
|
3817
|
+
id: el.id || null,
|
|
3818
|
+
name: el.getAttribute('name') || null,
|
|
3819
|
+
type: el.getAttribute('type') || null,
|
|
3820
|
+
role: el.getAttribute('role') || null,
|
|
3821
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
3822
|
+
testid: el.getAttribute('data-testid') || null,
|
|
3823
|
+
text: text || null
|
|
3824
|
+
};
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
// Get event target, handling shadow DOM via composedPath
|
|
3828
|
+
function getEventTarget(ev) {
|
|
3829
|
+
const path = ev.composedPath ? ev.composedPath() : null;
|
|
3830
|
+
if (path && path.length > 0) {
|
|
3831
|
+
for (const node of path) {
|
|
3832
|
+
if (node && node.nodeType === 1) return node;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
return ev.target && ev.target.nodeType === 1 ? ev.target : null;
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
// Find clickable ancestor (button, a, [role=button])
|
|
3839
|
+
function findClickableAncestor(el) {
|
|
3840
|
+
if (!el) return el;
|
|
3841
|
+
const clickable = el.closest('button, a, [role="button"], [role="link"]');
|
|
3842
|
+
return clickable || el;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// Check if element is a password input
|
|
3846
|
+
function isPasswordInput(el) {
|
|
3847
|
+
if (!el) return false;
|
|
3848
|
+
const tag = el.tagName.toLowerCase();
|
|
3849
|
+
if (tag !== 'input') return false;
|
|
3850
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
3851
|
+
return type === 'password';
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
// Get input value, redacting passwords
|
|
3855
|
+
function getInputValue(el) {
|
|
3856
|
+
if (isPasswordInput(el)) return '[REDACTED]';
|
|
3857
|
+
if (el.value !== undefined) return el.value;
|
|
3858
|
+
if (el.isContentEditable) return el.textContent || '';
|
|
3859
|
+
return '';
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
// Current timestamp
|
|
3863
|
+
function now() { return Date.now(); }
|
|
3864
|
+
|
|
3865
|
+
// Click handler
|
|
3866
|
+
window.addEventListener('click', function(ev) {
|
|
3867
|
+
const rawTarget = getEventTarget(ev);
|
|
3868
|
+
if (!rawTarget) return;
|
|
3869
|
+
|
|
3870
|
+
// Bubble up to clickable ancestor for better selectors
|
|
3871
|
+
const el = findClickableAncestor(rawTarget);
|
|
3872
|
+
|
|
3873
|
+
sendEvent({
|
|
3874
|
+
kind: 'click',
|
|
3875
|
+
timestamp: now(),
|
|
3876
|
+
url: location.href,
|
|
3877
|
+
element: getElementSummary(el),
|
|
3878
|
+
selectors: getSelectorCandidates(el),
|
|
3879
|
+
client: { x: ev.clientX, y: ev.clientY }
|
|
3880
|
+
});
|
|
3881
|
+
}, true);
|
|
3882
|
+
|
|
3883
|
+
// Double click handler
|
|
3884
|
+
window.addEventListener('dblclick', function(ev) {
|
|
3885
|
+
const rawTarget = getEventTarget(ev);
|
|
3886
|
+
if (!rawTarget) return;
|
|
3887
|
+
|
|
3888
|
+
const el = findClickableAncestor(rawTarget);
|
|
3889
|
+
|
|
3890
|
+
sendEvent({
|
|
3891
|
+
kind: 'dblclick',
|
|
3892
|
+
timestamp: now(),
|
|
3893
|
+
url: location.href,
|
|
3894
|
+
element: getElementSummary(el),
|
|
3895
|
+
selectors: getSelectorCandidates(el),
|
|
3896
|
+
client: { x: ev.clientX, y: ev.clientY }
|
|
3897
|
+
});
|
|
3898
|
+
}, true);
|
|
3899
|
+
|
|
3900
|
+
// Input handler (for text inputs, textareas, contenteditable)
|
|
3901
|
+
window.addEventListener('input', function(ev) {
|
|
3902
|
+
const el = getEventTarget(ev);
|
|
3903
|
+
if (!el) return;
|
|
3904
|
+
|
|
3905
|
+
const tag = el.tagName.toLowerCase();
|
|
3906
|
+
const isTexty = tag === 'input' || tag === 'textarea' || el.isContentEditable;
|
|
3907
|
+
if (!isTexty) return;
|
|
3908
|
+
|
|
3909
|
+
sendEvent({
|
|
3910
|
+
kind: 'input',
|
|
3911
|
+
timestamp: now(),
|
|
3912
|
+
url: location.href,
|
|
3913
|
+
element: getElementSummary(el),
|
|
3914
|
+
selectors: getSelectorCandidates(el),
|
|
3915
|
+
value: getInputValue(el)
|
|
3916
|
+
});
|
|
3917
|
+
}, true);
|
|
3918
|
+
|
|
3919
|
+
// Change handler (for select, checkbox, radio)
|
|
3920
|
+
window.addEventListener('change', function(ev) {
|
|
3921
|
+
const el = getEventTarget(ev);
|
|
3922
|
+
if (!el) return;
|
|
3923
|
+
|
|
3924
|
+
const tag = el.tagName.toLowerCase();
|
|
3925
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
3926
|
+
const isCheckable = type === 'checkbox' || type === 'radio';
|
|
3927
|
+
|
|
3928
|
+
sendEvent({
|
|
3929
|
+
kind: 'change',
|
|
3930
|
+
timestamp: now(),
|
|
3931
|
+
url: location.href,
|
|
3932
|
+
element: getElementSummary(el),
|
|
3933
|
+
selectors: getSelectorCandidates(el),
|
|
3934
|
+
value: isCheckable ? undefined : getInputValue(el),
|
|
3935
|
+
checked: isCheckable ? el.checked : undefined
|
|
3936
|
+
});
|
|
3937
|
+
}, true);
|
|
3938
|
+
|
|
3939
|
+
// Keydown handler (capture Enter for form submission)
|
|
3940
|
+
window.addEventListener('keydown', function(ev) {
|
|
3941
|
+
if (ev.key !== 'Enter') return;
|
|
3942
|
+
|
|
3943
|
+
const el = getEventTarget(ev);
|
|
3944
|
+
|
|
3945
|
+
sendEvent({
|
|
3946
|
+
kind: 'keydown',
|
|
3947
|
+
timestamp: now(),
|
|
3948
|
+
url: location.href,
|
|
3949
|
+
key: ev.key,
|
|
3950
|
+
element: el ? getElementSummary(el) : null,
|
|
3951
|
+
selectors: el ? getSelectorCandidates(el) : []
|
|
3952
|
+
});
|
|
3953
|
+
}, true);
|
|
3954
|
+
|
|
3955
|
+
// Submit handler
|
|
3956
|
+
window.addEventListener('submit', function(ev) {
|
|
3957
|
+
const el = getEventTarget(ev);
|
|
3958
|
+
|
|
3959
|
+
sendEvent({
|
|
3960
|
+
kind: 'submit',
|
|
3961
|
+
timestamp: now(),
|
|
3962
|
+
url: location.href,
|
|
3963
|
+
element: el ? getElementSummary(el) : null,
|
|
3964
|
+
selectors: el ? getSelectorCandidates(el) : []
|
|
3965
|
+
});
|
|
3966
|
+
}, true);
|
|
3967
|
+
})();`;
|
|
3968
|
+
|
|
3969
|
+
// src/recording/recorder.ts
|
|
3970
|
+
var Recorder = class {
|
|
3971
|
+
cdp;
|
|
3972
|
+
events = [];
|
|
3973
|
+
recording = false;
|
|
3974
|
+
startTime = 0;
|
|
3975
|
+
startUrl = "";
|
|
3976
|
+
bindingHandler = null;
|
|
3977
|
+
constructor(cdp) {
|
|
3978
|
+
this.cdp = cdp;
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* Check if recording is currently active.
|
|
3982
|
+
*/
|
|
3983
|
+
get isRecording() {
|
|
3984
|
+
return this.recording;
|
|
3985
|
+
}
|
|
3986
|
+
/**
|
|
3987
|
+
* Start recording browser interactions.
|
|
3988
|
+
*
|
|
3989
|
+
* Sets up CDP bindings and injects the recorder script into
|
|
3990
|
+
* the current page and all future navigations.
|
|
3991
|
+
*/
|
|
3992
|
+
async start() {
|
|
3993
|
+
if (this.recording) {
|
|
3994
|
+
throw new Error("Recording already in progress");
|
|
3995
|
+
}
|
|
3996
|
+
this.events = [];
|
|
3997
|
+
this.startTime = Date.now();
|
|
3998
|
+
this.recording = true;
|
|
3999
|
+
await this.cdp.send("Runtime.enable");
|
|
4000
|
+
await this.cdp.send("Page.enable");
|
|
4001
|
+
try {
|
|
4002
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
4003
|
+
expression: "location.href",
|
|
4004
|
+
returnByValue: true
|
|
4005
|
+
});
|
|
4006
|
+
this.startUrl = result.result.value;
|
|
4007
|
+
} catch {
|
|
4008
|
+
this.startUrl = "";
|
|
4009
|
+
}
|
|
4010
|
+
await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
|
|
4011
|
+
await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
4012
|
+
source: RECORDER_SCRIPT
|
|
4013
|
+
});
|
|
4014
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
4015
|
+
expression: RECORDER_SCRIPT,
|
|
4016
|
+
awaitPromise: false
|
|
4017
|
+
});
|
|
4018
|
+
this.bindingHandler = (params) => {
|
|
4019
|
+
if (params["name"] === RECORDER_BINDING_NAME) {
|
|
4020
|
+
this.handleBindingCall(params["payload"]);
|
|
4021
|
+
}
|
|
4022
|
+
};
|
|
4023
|
+
this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Stop recording and return aggregated output.
|
|
4027
|
+
*
|
|
4028
|
+
* Returns a RecordingOutput with steps compatible with page.batch().
|
|
4029
|
+
*/
|
|
4030
|
+
async stop() {
|
|
4031
|
+
if (!this.recording) {
|
|
4032
|
+
throw new Error("No recording in progress");
|
|
4033
|
+
}
|
|
4034
|
+
this.recording = false;
|
|
4035
|
+
const duration = Date.now() - this.startTime;
|
|
4036
|
+
if (this.bindingHandler) {
|
|
4037
|
+
this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
|
|
4038
|
+
this.bindingHandler = null;
|
|
4039
|
+
}
|
|
4040
|
+
const steps = aggregateEvents(this.events, this.startUrl);
|
|
4041
|
+
return {
|
|
4042
|
+
recordedAt: new Date(this.startTime).toISOString(),
|
|
4043
|
+
startUrl: this.startUrl,
|
|
4044
|
+
duration,
|
|
4045
|
+
steps
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* Get raw recorded events (for debugging).
|
|
4050
|
+
*/
|
|
4051
|
+
getEvents() {
|
|
4052
|
+
return [...this.events];
|
|
4053
|
+
}
|
|
4054
|
+
/**
|
|
4055
|
+
* Handle incoming binding call from the browser.
|
|
4056
|
+
*/
|
|
4057
|
+
handleBindingCall(payload) {
|
|
4058
|
+
if (!this.recording) return;
|
|
4059
|
+
try {
|
|
4060
|
+
const event = JSON.parse(payload);
|
|
4061
|
+
this.events.push(event);
|
|
4062
|
+
} catch {
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
};
|
|
4066
|
+
|
|
4067
|
+
// src/cli/commands/record.ts
|
|
4068
|
+
var RECORD_HELP = `
|
|
4069
|
+
bp record - Record browser actions to JSON
|
|
4070
|
+
|
|
4071
|
+
Usage:
|
|
4072
|
+
bp record [options]
|
|
4073
|
+
|
|
4074
|
+
Options:
|
|
4075
|
+
-s, --session [id] Session to use:
|
|
4076
|
+
- omit -s: auto-connect to local browser
|
|
4077
|
+
- -s alone: use most recent session
|
|
4078
|
+
- -s <id>: use specific session
|
|
4079
|
+
-f, --file <path> Output file (default: recording.json)
|
|
4080
|
+
--timeout <ms> Auto-stop after timeout (optional)
|
|
4081
|
+
-h, --help Show this help
|
|
4082
|
+
|
|
4083
|
+
Examples:
|
|
4084
|
+
bp record # Auto-connect to local Chrome
|
|
4085
|
+
bp record -s # Use most recent session
|
|
4086
|
+
bp record -s mysession # Use specific session
|
|
4087
|
+
bp record -f login.json # Save to specific file
|
|
4088
|
+
bp record --timeout 60000 # Auto-stop after 60s
|
|
4089
|
+
|
|
4090
|
+
Recording captures: clicks, inputs, form submissions, navigation.
|
|
4091
|
+
Password fields are automatically redacted as [REDACTED].
|
|
4092
|
+
|
|
4093
|
+
Press Ctrl+C to stop recording and save.
|
|
4094
|
+
`;
|
|
4095
|
+
function parseRecordArgs(args) {
|
|
4096
|
+
const options = {};
|
|
4097
|
+
for (let i = 0; i < args.length; i++) {
|
|
4098
|
+
const arg = args[i];
|
|
4099
|
+
if (arg === "-f" || arg === "--file") {
|
|
4100
|
+
options.file = args[++i];
|
|
4101
|
+
} else if (arg === "--timeout") {
|
|
4102
|
+
options.timeout = Number.parseInt(args[++i] ?? "", 10);
|
|
4103
|
+
} else if (arg === "-h" || arg === "--help") {
|
|
4104
|
+
options.help = true;
|
|
4105
|
+
} else if (arg === "-s" || arg === "--session") {
|
|
4106
|
+
const nextArg = args[i + 1];
|
|
4107
|
+
if (!nextArg || nextArg.startsWith("-")) {
|
|
4108
|
+
options.useLatestSession = true;
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
return options;
|
|
4113
|
+
}
|
|
4114
|
+
async function resolveConnection(sessionId, useLatestSession, trace) {
|
|
4115
|
+
if (sessionId) {
|
|
4116
|
+
const session2 = await loadSession(sessionId);
|
|
4117
|
+
const browser2 = await connect({
|
|
4118
|
+
provider: session2.provider,
|
|
4119
|
+
wsUrl: session2.wsUrl,
|
|
4120
|
+
debug: trace
|
|
4121
|
+
});
|
|
4122
|
+
return { browser: browser2, session: session2, isNewSession: false };
|
|
4123
|
+
}
|
|
4124
|
+
if (useLatestSession) {
|
|
4125
|
+
const session2 = await getDefaultSession();
|
|
4126
|
+
if (!session2) {
|
|
4127
|
+
throw new Error(
|
|
4128
|
+
'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
|
|
4129
|
+
);
|
|
4130
|
+
}
|
|
4131
|
+
const browser2 = await connect({
|
|
4132
|
+
provider: session2.provider,
|
|
4133
|
+
wsUrl: session2.wsUrl,
|
|
4134
|
+
debug: trace
|
|
4135
|
+
});
|
|
4136
|
+
return { browser: browser2, session: session2, isNewSession: false };
|
|
4137
|
+
}
|
|
4138
|
+
let wsUrl;
|
|
4139
|
+
try {
|
|
4140
|
+
wsUrl = await getBrowserWebSocketUrl("localhost:9222");
|
|
4141
|
+
} catch {
|
|
4142
|
+
throw new Error(
|
|
4143
|
+
"Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp record -s <session-id>\n 3. Use latest session: bp record -s"
|
|
4144
|
+
);
|
|
4145
|
+
}
|
|
4146
|
+
const browser = await connect({
|
|
4147
|
+
provider: "generic",
|
|
4148
|
+
wsUrl,
|
|
4149
|
+
debug: trace
|
|
4150
|
+
});
|
|
4151
|
+
const page = await browser.page();
|
|
4152
|
+
const currentUrl = await page.url();
|
|
4153
|
+
const newSessionId = generateSessionId();
|
|
4154
|
+
const session = {
|
|
4155
|
+
id: newSessionId,
|
|
4156
|
+
provider: "generic",
|
|
4157
|
+
wsUrl: browser.wsUrl,
|
|
4158
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4159
|
+
lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4160
|
+
currentUrl
|
|
4161
|
+
};
|
|
4162
|
+
await saveSession(session);
|
|
4163
|
+
return { browser, session, isNewSession: true };
|
|
4164
|
+
}
|
|
4165
|
+
async function recordCommand(args, globalOptions) {
|
|
4166
|
+
const options = parseRecordArgs(args);
|
|
4167
|
+
if (options.help || globalOptions.help) {
|
|
4168
|
+
console.log(RECORD_HELP);
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4171
|
+
const outputFile = options.file ?? "recording.json";
|
|
4172
|
+
const { browser, session, isNewSession } = await resolveConnection(
|
|
4173
|
+
globalOptions.session,
|
|
4174
|
+
options.useLatestSession ?? false,
|
|
4175
|
+
globalOptions.trace ?? false
|
|
4176
|
+
);
|
|
4177
|
+
if (isNewSession) {
|
|
4178
|
+
console.log(`Created new session: ${session.id}`);
|
|
4179
|
+
}
|
|
4180
|
+
const page = await browser.page();
|
|
4181
|
+
const cdp = page.cdpClient;
|
|
4182
|
+
const recorder = new Recorder(cdp);
|
|
4183
|
+
let stopping = false;
|
|
4184
|
+
async function stopAndSave() {
|
|
4185
|
+
if (stopping) return;
|
|
4186
|
+
stopping = true;
|
|
4187
|
+
try {
|
|
4188
|
+
const recording = await recorder.stop();
|
|
4189
|
+
const fs = await import("fs/promises");
|
|
4190
|
+
await fs.writeFile(outputFile, JSON.stringify(recording, null, 2));
|
|
4191
|
+
const currentUrl = await page.url();
|
|
4192
|
+
await updateSession(session.id, { currentUrl });
|
|
4193
|
+
await browser.disconnect();
|
|
4194
|
+
console.log(`
|
|
4195
|
+
Saved ${recording.steps.length} steps to ${outputFile}`);
|
|
4196
|
+
if (globalOptions.output === "json") {
|
|
4197
|
+
output(
|
|
4198
|
+
{
|
|
4199
|
+
success: true,
|
|
4200
|
+
file: outputFile,
|
|
4201
|
+
steps: recording.steps.length,
|
|
4202
|
+
duration: recording.duration
|
|
4203
|
+
},
|
|
4204
|
+
"json"
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
process.exit(0);
|
|
4208
|
+
} catch (error) {
|
|
4209
|
+
console.error("Error saving recording:", error);
|
|
4210
|
+
process.exit(1);
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
process.on("SIGINT", stopAndSave);
|
|
4214
|
+
process.on("SIGTERM", stopAndSave);
|
|
4215
|
+
if (options.timeout && options.timeout > 0) {
|
|
4216
|
+
setTimeout(stopAndSave, options.timeout);
|
|
4217
|
+
}
|
|
4218
|
+
await recorder.start();
|
|
4219
|
+
console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
|
|
4220
|
+
console.log(`Session: ${session.id}`);
|
|
4221
|
+
console.log(`URL: ${await page.url()}`);
|
|
4222
|
+
}
|
|
4223
|
+
|
|
3381
4224
|
// src/cli/commands/screenshot.ts
|
|
3382
4225
|
function parseScreenshotArgs(args) {
|
|
3383
4226
|
const options = {};
|
|
@@ -3551,11 +4394,13 @@ Commands:
|
|
|
3551
4394
|
quickstart Getting started guide (start here!)
|
|
3552
4395
|
connect Create browser session
|
|
3553
4396
|
exec Execute actions
|
|
4397
|
+
record Record browser actions to JSON
|
|
3554
4398
|
snapshot Get page with element refs
|
|
3555
4399
|
text Extract text content
|
|
3556
4400
|
screenshot Take screenshot
|
|
3557
4401
|
close Close session
|
|
3558
4402
|
list List sessions
|
|
4403
|
+
clean Clean up old sessions
|
|
3559
4404
|
actions Complete action reference
|
|
3560
4405
|
|
|
3561
4406
|
Options:
|
|
@@ -3570,6 +4415,8 @@ Examples:
|
|
|
3570
4415
|
bp exec '{"action":"goto","url":"https://example.com"}'
|
|
3571
4416
|
bp snapshot --format text
|
|
3572
4417
|
bp exec '{"action":"click","selector":"ref:e3"}'
|
|
4418
|
+
bp record # Record from local browser
|
|
4419
|
+
bp record -s -f login.json # Record from latest session
|
|
3573
4420
|
|
|
3574
4421
|
Run 'bp quickstart' for CLI workflow guide.
|
|
3575
4422
|
Run 'bp actions' for complete action reference.
|
|
@@ -3602,7 +4449,10 @@ function output(data, format = "pretty") {
|
|
|
3602
4449
|
if (typeof data === "string") {
|
|
3603
4450
|
console.log(data);
|
|
3604
4451
|
} else if (typeof data === "object" && data !== null) {
|
|
3605
|
-
prettyPrint(data);
|
|
4452
|
+
const { truncated } = prettyPrint(data);
|
|
4453
|
+
if (truncated) {
|
|
4454
|
+
console.log("\n(Output truncated. Use -o json for full data)");
|
|
4455
|
+
}
|
|
3606
4456
|
} else {
|
|
3607
4457
|
console.log(data);
|
|
3608
4458
|
}
|
|
@@ -3610,16 +4460,20 @@ function output(data, format = "pretty") {
|
|
|
3610
4460
|
}
|
|
3611
4461
|
function prettyPrint(obj, indent = 0) {
|
|
3612
4462
|
const prefix = " ".repeat(indent);
|
|
4463
|
+
let truncated = false;
|
|
3613
4464
|
for (const [key, value] of Object.entries(obj)) {
|
|
3614
4465
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
3615
4466
|
console.log(`${prefix}${key}:`);
|
|
3616
|
-
prettyPrint(value, indent + 1);
|
|
4467
|
+
const result = prettyPrint(value, indent + 1);
|
|
4468
|
+
if (result.truncated) truncated = true;
|
|
3617
4469
|
} else if (Array.isArray(value)) {
|
|
3618
4470
|
console.log(`${prefix}${key}: [${value.length} items]`);
|
|
4471
|
+
truncated = true;
|
|
3619
4472
|
} else {
|
|
3620
4473
|
console.log(`${prefix}${key}: ${value}`);
|
|
3621
4474
|
}
|
|
3622
4475
|
}
|
|
4476
|
+
return { truncated };
|
|
3623
4477
|
}
|
|
3624
4478
|
async function main() {
|
|
3625
4479
|
const args = process.argv.slice(2);
|
|
@@ -3659,9 +4513,15 @@ async function main() {
|
|
|
3659
4513
|
case "list":
|
|
3660
4514
|
await listCommand(remaining, options);
|
|
3661
4515
|
break;
|
|
4516
|
+
case "clean":
|
|
4517
|
+
await cleanCommand(remaining, options);
|
|
4518
|
+
break;
|
|
3662
4519
|
case "actions":
|
|
3663
4520
|
await actionsCommand();
|
|
3664
4521
|
break;
|
|
4522
|
+
case "record":
|
|
4523
|
+
await recordCommand(remaining, options);
|
|
4524
|
+
break;
|
|
3665
4525
|
case "help":
|
|
3666
4526
|
case "--help":
|
|
3667
4527
|
case "-h":
|