@monoes/monobrowse 1.0.3 → 1.0.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/dist/src/__tests__/batch-eval-parsing.test.d.ts +2 -0
- package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +1 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js +47 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -0
- package/dist/src/__tests__/ref-cache.test.js +36 -0
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/browser/action-builder/analyzer.js.map +1 -1
- package/dist/src/browser/actions.d.ts +1 -1
- package/dist/src/browser/actions.d.ts.map +1 -1
- package/dist/src/browser/actions.js +19 -2
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/browser.d.ts +10 -0
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +111 -3
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +1 -1
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/dashboard/server.d.ts.map +1 -1
- package/dist/src/browser/dashboard/server.js +37 -2
- package/dist/src/browser/dashboard/server.js.map +1 -1
- package/dist/src/browser/dashboard/ui.html +1 -1
- package/dist/src/browser/find.js.map +1 -1
- package/dist/src/browser/network.d.ts +7 -0
- package/dist/src/browser/network.d.ts.map +1 -1
- package/dist/src/browser/network.js +10 -0
- package/dist/src/browser/network.js.map +1 -1
- package/dist/src/browser/pdf.js.map +1 -1
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/browser/record.d.ts +3 -0
- package/dist/src/browser/record.d.ts.map +1 -1
- package/dist/src/browser/record.js +37 -5
- package/dist/src/browser/record.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +20 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +50 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/screenshot.js.map +1 -1
- package/dist/src/browser/session.d.ts.map +1 -1
- package/dist/src/browser/session.js +12 -7
- package/dist/src/browser/session.js.map +1 -1
- package/dist/src/browser/snapshot.js.map +1 -1
- package/dist/src/browser/trace.js.map +1 -1
- package/dist/src/cli/action.js.map +1 -1
- package/dist/src/cli/commands.d.ts +22 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +246 -27
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/output.d.ts.map +1 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/__tests__/batch-eval-parsing.test.ts +52 -0
- package/src/__tests__/ref-cache.test.ts +41 -0
- package/src/browser/actions.ts +22 -2
- package/src/browser/browser.ts +115 -2
- package/src/browser/cdp.ts +1 -1
- package/src/browser/dashboard/server.ts +37 -2
- package/src/browser/dashboard/ui.html +1 -1
- package/src/browser/network.ts +11 -0
- package/src/browser/record.ts +43 -6
- package/src/browser/ref-cache.ts +51 -0
- package/src/browser/session.ts +13 -7
- package/src/cli/commands.ts +239 -27
package/src/cli/commands.ts
CHANGED
|
@@ -10,16 +10,42 @@ import { createPlatformCommand } from './platform.js';
|
|
|
10
10
|
import type { CdpClient, ElementRef, NetworkRoute, FindAction } from '../index.js';
|
|
11
11
|
|
|
12
12
|
// Runtime state (single session per CLI process)
|
|
13
|
+
const DEFAULT_PORT = 9222;
|
|
13
14
|
let _client: CdpClient | null = null;
|
|
14
15
|
let _sessionId = '';
|
|
15
16
|
let _targetId = '';
|
|
16
|
-
let _port =
|
|
17
|
+
let _port = DEFAULT_PORT;
|
|
17
18
|
let _refs: Map<string, ElementRef> = new Map();
|
|
18
19
|
|
|
19
20
|
async function getBrowser() {
|
|
20
21
|
return import('../index.js');
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// Each CLI invocation is a fresh process, so this module's `_port` variable
|
|
25
|
+
// is always freshly initialized to DEFAULT_PORT — a `--port 9333` passed to
|
|
26
|
+
// an earlier `open` command has no effect on this process's own default.
|
|
27
|
+
// Resolve the persisted "active port" (written by `open --port N`, read via
|
|
28
|
+
// ref-cache.ts's saveActivePort/loadActivePort) so subsequent commands
|
|
29
|
+
// attach to the browser the user actually opened instead of silently
|
|
30
|
+
// launching/attaching to a second, unrelated Chrome instance on 9222.
|
|
31
|
+
async function resolveDefaultPort(browser: Awaited<ReturnType<typeof getBrowser>>): Promise<number> {
|
|
32
|
+
const persisted = await browser.loadActivePortInfo();
|
|
33
|
+
if (!persisted) return DEFAULT_PORT;
|
|
34
|
+
if (!persisted.launched) {
|
|
35
|
+
// connect-origin port: the browser belongs to someone else. If it died,
|
|
36
|
+
// relaunching our own headless Chrome on that port would squat the
|
|
37
|
+
// user's debug port and silently swap which browser commands act on —
|
|
38
|
+
// fail loudly instead.
|
|
39
|
+
try {
|
|
40
|
+
await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(1500) });
|
|
41
|
+
} catch {
|
|
42
|
+
await browser.clearActivePort();
|
|
43
|
+
throw new Error(`Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return persisted.port;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
async function ensureConnected(port: number, targetId?: string) {
|
|
24
50
|
const browser = await getBrowser();
|
|
25
51
|
if (!_client || !_client.isConnected()) {
|
|
@@ -30,13 +56,14 @@ async function ensureConnected(port: number, targetId?: string) {
|
|
|
30
56
|
browser.teardownConsoleCapture(_sessionId);
|
|
31
57
|
_client.close();
|
|
32
58
|
}
|
|
33
|
-
|
|
59
|
+
const effectivePort = port === DEFAULT_PORT ? await resolveDefaultPort(browser) : port;
|
|
60
|
+
_port = await browser.launchBrowser({ port: effectivePort, headless: true });
|
|
34
61
|
const conn = await browser.connectToTarget(_port, targetId);
|
|
35
62
|
_client = conn.client;
|
|
36
63
|
_sessionId = conn.sessionId;
|
|
37
64
|
_targetId = conn.target.id;
|
|
38
65
|
_refs = new Map();
|
|
39
|
-
await hydrateRefsFromCache(browser, _targetId);
|
|
66
|
+
await hydrateRefsFromCache(browser, _targetId, conn.target.url);
|
|
40
67
|
}
|
|
41
68
|
return { client: _client!, sessionId: _sessionId, targetId: _targetId };
|
|
42
69
|
}
|
|
@@ -46,9 +73,29 @@ async function ensureConnected(port: number, targetId?: string) {
|
|
|
46
73
|
// command runs. Rehydrate it from the on-disk ref cache (written by
|
|
47
74
|
// captureSnapshot call sites below) so refs resolved by a previous process
|
|
48
75
|
// remain usable. Falls back silently to an empty Map if no cache exists.
|
|
49
|
-
|
|
76
|
+
//
|
|
77
|
+
// The cached `url` field (captured at snapshot time) is compared against the
|
|
78
|
+
// browser's CURRENT url (`currentUrl`, from the just-fetched target info).
|
|
79
|
+
// A stale `backendDOMNodeId` can still successfully resolve via
|
|
80
|
+
// DOM.getBoxModel even after a same-tab SPA navigation or DOM mutation
|
|
81
|
+
// changed what's actually at those coordinates — so a mismatch here means
|
|
82
|
+
// EVERY ref in the cache is potentially pointing at the wrong element. We
|
|
83
|
+
// hard-invalidate (skip hydration entirely, so any @eN lookup fails loudly
|
|
84
|
+
// via resolveRef's "not found" error) rather than the weaker 30s time-based
|
|
85
|
+
// staleness check below, which only warns.
|
|
86
|
+
async function hydrateRefsFromCache(
|
|
87
|
+
browser: Awaited<ReturnType<typeof getBrowser>>,
|
|
88
|
+
targetId: string,
|
|
89
|
+
currentUrl: string
|
|
90
|
+
): Promise<void> {
|
|
50
91
|
const cached = await browser.loadRefCache(targetId);
|
|
51
92
|
if (!cached) return;
|
|
93
|
+
if (currentUrl && cached.url && currentUrl !== cached.url) {
|
|
94
|
+
output.printError(
|
|
95
|
+
`Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`
|
|
96
|
+
);
|
|
97
|
+
return; // leave _refs empty — do not attempt to resolve refs against a different page
|
|
98
|
+
}
|
|
52
99
|
_refs = cached.refs;
|
|
53
100
|
if (cached.stale) {
|
|
54
101
|
output.printWarning(
|
|
@@ -145,11 +192,14 @@ async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
|
145
192
|
const authCookies = await browser.getCookies(_client, _sessionId).catch(() => [] as unknown[]);
|
|
146
193
|
const authLocalStorage = await browser.getLocalStorage(_client, _sessionId).catch(() => ({}) as Record<string, string>);
|
|
147
194
|
|
|
148
|
-
// Close headed session
|
|
195
|
+
// Close headed session — actually terminate the underlying Chrome process
|
|
196
|
+
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
197
|
+
// connection, so the visible authenticated window doesn't linger forever.
|
|
149
198
|
browser.teardownRouteInterception(_sessionId);
|
|
150
199
|
browser.stopRequestCapture(_sessionId);
|
|
151
200
|
browser.teardownDialogHandling(_sessionId);
|
|
152
201
|
browser.teardownConsoleCapture(_sessionId);
|
|
202
|
+
await browser.closeBrowser(_client, headedPort);
|
|
153
203
|
_client.close();
|
|
154
204
|
_client = null;
|
|
155
205
|
_sessionId = '';
|
|
@@ -232,11 +282,18 @@ const openCommand: Command = {
|
|
|
232
282
|
}
|
|
233
283
|
|
|
234
284
|
_port = await browser.launchBrowser({ port, headless: !forceHeaded });
|
|
285
|
+
// Persist the active port so subsequent CLI invocations (each a fresh
|
|
286
|
+
// process) default to attaching here instead of hardcoded 9222.
|
|
287
|
+
await browser.saveActivePort(_port);
|
|
235
288
|
const conn = await browser.connectToTarget(_port);
|
|
236
289
|
_client = conn.client;
|
|
237
290
|
_sessionId = conn.sessionId;
|
|
238
291
|
_targetId = conn.target.id;
|
|
239
292
|
_refs = new Map();
|
|
293
|
+
// A snapshot taken before this navigation is no longer valid for the new
|
|
294
|
+
// page — drop the persisted ref cache so a stale process's weak
|
|
295
|
+
// time-based check can't resurrect it before the next explicit snapshot.
|
|
296
|
+
await browser.clearRefCache();
|
|
240
297
|
|
|
241
298
|
if (ctx.flags.state && ctx.flags.session) {
|
|
242
299
|
output.printWarning('Both --state and --session provided; --state takes precedence');
|
|
@@ -298,11 +355,8 @@ const snapshotCommand: Command = {
|
|
|
298
355
|
await browser.saveRefCache(_targetId, result.url, _refs);
|
|
299
356
|
|
|
300
357
|
const applyOutputLimits = (text: string): string => {
|
|
301
|
-
let out = text;
|
|
302
358
|
const maxOutput = ctx.flags['max-output'] as number | undefined;
|
|
303
|
-
|
|
304
|
-
out = out.slice(0, maxOutput) + `\n[... truncated at ${maxOutput} chars]`;
|
|
305
|
-
}
|
|
359
|
+
let out = maxOutput ? truncateForOutput(text, maxOutput) : text;
|
|
306
360
|
if (ctx.flags['content-boundaries']) {
|
|
307
361
|
const nonce = Math.random().toString(36).slice(2, 10);
|
|
308
362
|
out = `MONOMIND_PAGE_CONTENT nonce=${nonce} origin=${result.url}\n${out}\nEND_MONOMIND_PAGE_CONTENT nonce=${nonce}`;
|
|
@@ -500,16 +554,29 @@ const waitCommand: Command = {
|
|
|
500
554
|
const rawTimeout = Math.min((ctx.flags.timeout as number) ?? 30000, MAX_DOWNLOAD_TIMEOUT);
|
|
501
555
|
const finalPath = await new Promise<string>((resolve, reject) => {
|
|
502
556
|
let guid = '';
|
|
557
|
+
let settled = false;
|
|
503
558
|
// C2: capture off() functions to avoid listener leaks
|
|
504
559
|
const offBegin = client.on('Browser.downloadWillBegin', (params: Record<string, unknown>) => { guid = params.guid as string; });
|
|
505
560
|
let offProgress: (() => void) | undefined;
|
|
506
561
|
// cleanup defined before setTimeout so the timeout callback can call it
|
|
507
562
|
let tid: ReturnType<typeof setTimeout>;
|
|
508
|
-
|
|
509
|
-
|
|
563
|
+
let pollTid: ReturnType<typeof setInterval> | undefined;
|
|
564
|
+
const cleanup = () => { clearTimeout(tid); clearInterval(pollTid); offBegin?.(); offProgress?.(); };
|
|
565
|
+
const finish = (path: string) => {
|
|
566
|
+
if (settled) return;
|
|
567
|
+
settled = true;
|
|
568
|
+
cleanup();
|
|
569
|
+
resolve(path);
|
|
570
|
+
};
|
|
571
|
+
const fail = (err: Error) => {
|
|
572
|
+
if (settled) return;
|
|
573
|
+
settled = true;
|
|
574
|
+
cleanup();
|
|
575
|
+
reject(err);
|
|
576
|
+
};
|
|
577
|
+
tid = setTimeout(() => fail(new Error('Download timed out')), rawTimeout);
|
|
510
578
|
offProgress = client.on('Browser.downloadProgress', async (params: Record<string, unknown>) => {
|
|
511
579
|
if (params.guid === guid && params.state === 'completed') {
|
|
512
|
-
cleanup();
|
|
513
580
|
const { readdir, rename, rmdir } = await import('fs/promises');
|
|
514
581
|
const files = await readdir(downloadDir);
|
|
515
582
|
if (files.length > 0) {
|
|
@@ -517,16 +584,40 @@ const waitCommand: Command = {
|
|
|
517
584
|
await mkdir(dirname(savePath), { recursive: true });
|
|
518
585
|
await rename(src, savePath);
|
|
519
586
|
await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
|
|
520
|
-
|
|
587
|
+
finish(savePath);
|
|
521
588
|
} else {
|
|
522
589
|
await rmdir(downloadDir).catch(() => {}); // I1: cleanup temp dir
|
|
523
|
-
|
|
590
|
+
fail(new Error('Download completed but no file found'));
|
|
524
591
|
}
|
|
525
592
|
} else if (params.guid === guid && params.state === 'canceled') {
|
|
526
|
-
|
|
527
|
-
reject(new Error('Download was canceled'));
|
|
593
|
+
fail(new Error('Download was canceled'));
|
|
528
594
|
}
|
|
529
595
|
});
|
|
596
|
+
|
|
597
|
+
// Fallback for the empty-guid race: this process's listener attaches
|
|
598
|
+
// AFTER a separate `click` process may have already started (and even
|
|
599
|
+
// finished) the download — so downloadWillBegin/downloadProgress for it
|
|
600
|
+
// were never observed here. While guid is still empty, poll the expected
|
|
601
|
+
// output path directly: if it already exists with a stable, non-zero
|
|
602
|
+
// size across two consecutive checks, treat the download as complete.
|
|
603
|
+
let lastSize = -1;
|
|
604
|
+
let stableReads = 0;
|
|
605
|
+
pollTid = setInterval(async () => {
|
|
606
|
+
if (settled || guid) return; // a real CDP event has taken over
|
|
607
|
+
try {
|
|
608
|
+
const { stat } = await import('fs/promises');
|
|
609
|
+
const st = await stat(savePath);
|
|
610
|
+
if (st.isFile() && st.size > 0 && st.size === lastSize) {
|
|
611
|
+
stableReads++;
|
|
612
|
+
if (stableReads >= 2) finish(savePath);
|
|
613
|
+
} else {
|
|
614
|
+
stableReads = 0;
|
|
615
|
+
}
|
|
616
|
+
lastSize = st.size;
|
|
617
|
+
} catch {
|
|
618
|
+
// savePath not present yet — keep polling until timeout
|
|
619
|
+
}
|
|
620
|
+
}, 300);
|
|
530
621
|
});
|
|
531
622
|
output.printSuccess(`Download saved: ${finalPath}`);
|
|
532
623
|
return { success: true, data: { path: finalPath } };
|
|
@@ -802,6 +893,10 @@ const navigateCommand: Command = {
|
|
|
802
893
|
throw new Error(`Unknown direction: ${direction}. Use: back|forward|reload`);
|
|
803
894
|
}
|
|
804
895
|
|
|
896
|
+
// Refs captured before this navigation may now point at different content.
|
|
897
|
+
_refs = new Map();
|
|
898
|
+
await browser.clearRefCache();
|
|
899
|
+
|
|
805
900
|
output.printSuccess(`Navigated: ${direction}`);
|
|
806
901
|
return { success: true };
|
|
807
902
|
},
|
|
@@ -1107,12 +1202,26 @@ const networkCommand: Command = {
|
|
|
1107
1202
|
},
|
|
1108
1203
|
};
|
|
1109
1204
|
|
|
1205
|
+
// Default output cap for eval when --max-output isn't passed explicitly —
|
|
1206
|
+
// print(String(result)) had NO limit at all (unlike snapshot's --max-output),
|
|
1207
|
+
// so e.g. `eval "document.documentElement.outerHTML"` could dump megabytes
|
|
1208
|
+
// straight into an agent's context. An explicit --max-output still overrides
|
|
1209
|
+
// this default.
|
|
1210
|
+
const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
|
|
1211
|
+
|
|
1212
|
+
function truncateForOutput(text: string, maxOutput: number): string {
|
|
1213
|
+
if (!(maxOutput > 0) || text.length <= maxOutput) return text;
|
|
1214
|
+
return text.slice(0, maxOutput) + `\n[... truncated at ${maxOutput} chars]`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1110
1217
|
const evalCommand: Command = {
|
|
1111
1218
|
name: 'eval',
|
|
1112
1219
|
description: 'Evaluate JavaScript in page context. Usage: monomind browse eval "document.title"',
|
|
1113
1220
|
options: [
|
|
1114
1221
|
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
1115
1222
|
{ name: 'stdin', type: 'boolean', description: 'Read JS expression from stdin (heredoc-friendly for multiline scripts)', default: false },
|
|
1223
|
+
{ name: 'max-output', type: 'number', description: `Truncate printed output to N characters (default ${DEFAULT_EVAL_MAX_OUTPUT}; 0 disables truncation)` },
|
|
1224
|
+
{ name: 'timeout', type: 'number', description: 'Max ms to wait for evaluation to settle (default 30000)' },
|
|
1116
1225
|
],
|
|
1117
1226
|
action: async (ctx: CommandContext): Promise<CommandResult> => {
|
|
1118
1227
|
const { client, sessionId } = await ensureConnected(_port);
|
|
@@ -1126,12 +1235,14 @@ const evalCommand: Command = {
|
|
|
1126
1235
|
}
|
|
1127
1236
|
if (!expr) throw new Error('Usage: monomind browse eval "<expression>" (or pipe with --stdin)');
|
|
1128
1237
|
|
|
1129
|
-
const
|
|
1238
|
+
const timeoutMs = ctx.flags.timeout as number | undefined;
|
|
1239
|
+
const result = await browser.evaluateJs(client, sessionId, expr, timeoutMs);
|
|
1130
1240
|
|
|
1241
|
+
const maxOutput = (ctx.flags['max-output'] as number | undefined) ?? DEFAULT_EVAL_MAX_OUTPUT;
|
|
1131
1242
|
if (ctx.flags.json) {
|
|
1132
|
-
print(JSON.stringify({ data: result }));
|
|
1243
|
+
print(truncateForOutput(JSON.stringify({ data: result }), maxOutput));
|
|
1133
1244
|
} else {
|
|
1134
|
-
print(String(result ?? ''));
|
|
1245
|
+
print(truncateForOutput(String(result ?? ''), maxOutput));
|
|
1135
1246
|
}
|
|
1136
1247
|
|
|
1137
1248
|
return { success: true, data: { result } };
|
|
@@ -1165,9 +1276,54 @@ const closeCommand: Command = {
|
|
|
1165
1276
|
_sessionId = '';
|
|
1166
1277
|
_targetId = '';
|
|
1167
1278
|
_refs = new Map();
|
|
1279
|
+
// Session is gone — forget the persisted port and stale refs so later
|
|
1280
|
+
// invocations don't chase a dead endpoint (or the wrong elements).
|
|
1281
|
+
await browser.clearActivePort();
|
|
1282
|
+
await browser.clearRefCache();
|
|
1168
1283
|
output.printSuccess('Browser session closed');
|
|
1169
1284
|
} else {
|
|
1170
|
-
|
|
1285
|
+
// Each CLI invocation is a fresh process, so `close` almost always
|
|
1286
|
+
// lands here. The persisted port file is the real session handle:
|
|
1287
|
+
// if monobrowse LAUNCHED that browser (open), gracefully Browser.close
|
|
1288
|
+
// it — otherwise every open→close cycle leaks a headless Chrome. If we
|
|
1289
|
+
// merely ATTACHED to it (connect, launched:false), never kill it: it's
|
|
1290
|
+
// the user's own browser. Either way, forget the port and refs.
|
|
1291
|
+
const browser = await getBrowser();
|
|
1292
|
+
const persisted = await browser.loadActivePortInfo();
|
|
1293
|
+
if (persisted?.launched) {
|
|
1294
|
+
try {
|
|
1295
|
+
const conn = await browser.connectToTarget(persisted.port);
|
|
1296
|
+
try {
|
|
1297
|
+
await browser.closeBrowser(conn.client, persisted.port);
|
|
1298
|
+
} finally {
|
|
1299
|
+
// Always drop our websocket — a hung Browser.close must not keep
|
|
1300
|
+
// this CLI process's event loop alive.
|
|
1301
|
+
try { conn.client.close(); } catch { /* already gone */ }
|
|
1302
|
+
}
|
|
1303
|
+
// closeBrowser has no PID fallback in a fresh process (launchedPids
|
|
1304
|
+
// is per-process) — re-probe so we report what actually happened.
|
|
1305
|
+
// Poll briefly: Browser.close is acknowledged before the process
|
|
1306
|
+
// actually exits, so a single immediate probe false-alarms.
|
|
1307
|
+
let stillUp = true;
|
|
1308
|
+
const probeDeadline = Date.now() + 3000;
|
|
1309
|
+
while (stillUp && Date.now() < probeDeadline) {
|
|
1310
|
+
try {
|
|
1311
|
+
await fetch(`http://127.0.0.1:${persisted.port}/json/version`, { signal: AbortSignal.timeout(800) });
|
|
1312
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1313
|
+
} catch { stillUp = false; }
|
|
1314
|
+
}
|
|
1315
|
+
if (stillUp) output.printWarning(`Browser on port ${persisted.port} did not exit — kill it manually if needed`);
|
|
1316
|
+
else output.printSuccess(`Closed browser on port ${persisted.port}`);
|
|
1317
|
+
} catch {
|
|
1318
|
+
output.printInfo(`No browser answering on port ${persisted.port} — nothing to close`);
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
output.printInfo(persisted
|
|
1322
|
+
? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
|
|
1323
|
+
: 'No active browser session');
|
|
1324
|
+
}
|
|
1325
|
+
await browser.clearActivePort();
|
|
1326
|
+
await browser.clearRefCache();
|
|
1171
1327
|
}
|
|
1172
1328
|
return { success: true };
|
|
1173
1329
|
},
|
|
@@ -2297,6 +2453,10 @@ const pushstateCommand: Command = {
|
|
|
2297
2453
|
const url = ctx.args[0] as string;
|
|
2298
2454
|
if (!url) throw new Error('Usage: monomind browse pushstate <url>');
|
|
2299
2455
|
await browser.pushState(client, sessionId, url);
|
|
2456
|
+
// SPA navigation changes what's on the page without a full page load —
|
|
2457
|
+
// refs captured before this pushState call may now resolve to different content.
|
|
2458
|
+
_refs = new Map();
|
|
2459
|
+
await browser.clearRefCache();
|
|
2300
2460
|
output.printSuccess(`pushState: ${url}`);
|
|
2301
2461
|
return { success: true };
|
|
2302
2462
|
},
|
|
@@ -2322,6 +2482,54 @@ function tokenizeBatchCommand(input: string): string[] {
|
|
|
2322
2482
|
return tokens;
|
|
2323
2483
|
}
|
|
2324
2484
|
|
|
2485
|
+
/**
|
|
2486
|
+
* Splits one line of a `batch` command string into a subcommand name, its
|
|
2487
|
+
* args, and any recognized leading flags.
|
|
2488
|
+
*
|
|
2489
|
+
* `eval` is special-cased: its sole argument is a raw JS expression, which
|
|
2490
|
+
* legitimately contains its own string-literal quotes (e.g.
|
|
2491
|
+
* `eval document.querySelector('a')`). tokenizeBatchCommand's shell-style
|
|
2492
|
+
* word-splitting treats `'...'`/`"..."` as grouping delimiters and discards
|
|
2493
|
+
* them — correct for a value like `fill @e1 "some text"`, but for `eval` it
|
|
2494
|
+
* silently deletes the expression's own quotes, turning a string literal
|
|
2495
|
+
* into a bare (undefined) identifier reference and producing a
|
|
2496
|
+
* ReferenceError instead of evaluating the intended expression. For `eval`,
|
|
2497
|
+
* only its own known --flags are consumed from the front; everything after
|
|
2498
|
+
* them is taken verbatim as one argument, untouched by tokenization.
|
|
2499
|
+
*
|
|
2500
|
+
* Exported for direct unit testing — not part of the CLI's public API.
|
|
2501
|
+
*/
|
|
2502
|
+
export function parseBatchCommandLine(cmdStr: string): { subName: string; subArgs: string[]; flags: Record<string, unknown> } {
|
|
2503
|
+
const trimmed = cmdStr.trim();
|
|
2504
|
+
const evalMatch = trimmed.match(/^eval\b\s*/);
|
|
2505
|
+
if (!evalMatch) {
|
|
2506
|
+
const parts = tokenizeBatchCommand(trimmed);
|
|
2507
|
+
return { subName: parts[0], subArgs: parts.slice(1), flags: {} };
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
let rest = trimmed.slice(evalMatch[0].length);
|
|
2511
|
+
const flags: Record<string, unknown> = {};
|
|
2512
|
+
let consumedAnother = true;
|
|
2513
|
+
while (consumedAnother) {
|
|
2514
|
+
consumedAnother = false;
|
|
2515
|
+
const withValue = rest.match(/^--(max-output|timeout)\s+(-?\d+)\s*/);
|
|
2516
|
+
if (withValue) {
|
|
2517
|
+
flags[withValue[1]] = Number(withValue[2]);
|
|
2518
|
+
rest = rest.slice(withValue[0].length);
|
|
2519
|
+
consumedAnother = true;
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
const boolFlag = rest.match(/^--(json|stdin)\b\s*/);
|
|
2523
|
+
if (boolFlag) {
|
|
2524
|
+
flags[boolFlag[1]] = true;
|
|
2525
|
+
rest = rest.slice(boolFlag[0].length);
|
|
2526
|
+
consumedAnother = true;
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
return { subName: 'eval', subArgs: [rest], flags };
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2325
2533
|
const batchCommand: Command = {
|
|
2326
2534
|
name: 'batch',
|
|
2327
2535
|
description: 'Execute multiple commands. Usage: monomind browse batch "open url" "snapshot -i" "click @e1"',
|
|
@@ -2335,9 +2543,7 @@ const batchCommand: Command = {
|
|
|
2335
2543
|
|
|
2336
2544
|
const results: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
2337
2545
|
for (const cmdStr of commands) {
|
|
2338
|
-
const
|
|
2339
|
-
const subName = parts[0];
|
|
2340
|
-
const subArgs = parts.slice(1);
|
|
2546
|
+
const { subName, subArgs, flags: preParsedFlags } = parseBatchCommandLine(cmdStr);
|
|
2341
2547
|
|
|
2342
2548
|
const subCmd = browseCommand.subcommands?.find((s) => s.name === subName);
|
|
2343
2549
|
if (!subCmd?.action) {
|
|
@@ -2348,7 +2554,7 @@ const batchCommand: Command = {
|
|
|
2348
2554
|
}
|
|
2349
2555
|
|
|
2350
2556
|
try {
|
|
2351
|
-
const parsedFlags: CommandContext['flags'] = { _: [] };
|
|
2557
|
+
const parsedFlags: CommandContext['flags'] = { _: [], ...preParsedFlags };
|
|
2352
2558
|
const consumedIndices = new Set<number>();
|
|
2353
2559
|
// Parse --flags from subArgs, tracking which indices are flag names/values
|
|
2354
2560
|
for (let i = 0; i < subArgs.length; i++) {
|
|
@@ -2452,7 +2658,7 @@ const removeinitscriptCommand: Command = {
|
|
|
2452
2658
|
|
|
2453
2659
|
const connectCommand: Command = {
|
|
2454
2660
|
name: 'connect',
|
|
2455
|
-
description: 'Connect to existing Chrome instance. Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
|
|
2661
|
+
description: 'Connect to existing Chrome instance; later commands reuse this session (note: `open` without --port still launches on its own default). Usage: monomind browse connect [--port 9222] [--target <id>] [--auto-connect]',
|
|
2456
2662
|
options: [
|
|
2457
2663
|
{ name: 'port', short: 'p', type: 'number', description: 'CDP port', default: 9222 },
|
|
2458
2664
|
{ name: 'target', type: 'string', description: 'Target ID to attach to' },
|
|
@@ -2504,6 +2710,12 @@ const connectCommand: Command = {
|
|
|
2504
2710
|
_targetId = conn.target.id;
|
|
2505
2711
|
_port = port;
|
|
2506
2712
|
_refs = new Map();
|
|
2713
|
+
// Persist the port like `open` does — without this, the NEXT CLI process
|
|
2714
|
+
// (each command is a fresh process) resolves the hardcoded default and
|
|
2715
|
+
// tries to launch its own Chrome on 9222 instead of reusing this session.
|
|
2716
|
+
// launched:false marks this browser as someone else's — close must never
|
|
2717
|
+
// kill it, and a dead endpoint must not be silently relaunched.
|
|
2718
|
+
await browser.saveActivePort(port, { launched: false });
|
|
2507
2719
|
const url = await browser.getCurrentUrl(_client, _sessionId);
|
|
2508
2720
|
const title = await browser.getCurrentTitle(_client, _sessionId);
|
|
2509
2721
|
output.printSuccess(`Connected: ${title} (${url})`);
|
|
@@ -2542,7 +2754,7 @@ const recordCommand: Command = {
|
|
|
2542
2754
|
case 'restart': {
|
|
2543
2755
|
const prevStatus = browser.getRecordingStatus(sessionId);
|
|
2544
2756
|
let prevPath: string | undefined;
|
|
2545
|
-
if (prevStatus.recording) {
|
|
2757
|
+
if (prevStatus.recording || prevStatus.autoStopped) {
|
|
2546
2758
|
prevPath = await browser.stopRecording(client, sessionId, ctx.args[1] as string);
|
|
2547
2759
|
output.printInfo(`Previous recording saved: ${prevPath}`);
|
|
2548
2760
|
}
|
|
@@ -2556,7 +2768,7 @@ const recordCommand: Command = {
|
|
|
2556
2768
|
case 'status': {
|
|
2557
2769
|
const status = browser.getRecordingStatus(sessionId);
|
|
2558
2770
|
if (ctx.flags.json) print(JSON.stringify({ data: status }));
|
|
2559
|
-
else print(`Recording: ${status.recording} | Frames: ${status.frames}`);
|
|
2771
|
+
else print(`Recording: ${status.recording} | Frames: ${status.frames}${status.autoStopped ? ' (auto-stopped: buffer limit reached — run "record stop" to save)' : ''}`);
|
|
2560
2772
|
return { success: true, data: status };
|
|
2561
2773
|
}
|
|
2562
2774
|
default:
|