@monoes/monobrowse 1.0.22 → 1.0.25
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__/browse-concurrency.test.js +94 -1
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +137 -7
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/session-discovery.test.js +81 -1
- package/dist/src/__tests__/session-discovery.test.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +34 -13
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +6 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +4 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/cli/attention-wall.d.ts +12 -0
- package/dist/src/cli/attention-wall.d.ts.map +1 -0
- package/dist/src/cli/attention-wall.js +166 -0
- package/dist/src/cli/attention-wall.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +24 -11
- package/dist/src/cli/commands-session.js.map +1 -1
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +21 -8
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +17 -3
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +72 -162
- package/dist/src/cli/session.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browse-concurrency.test.ts +106 -1
- package/src/__tests__/browser-launch.test.ts +142 -7
- package/src/__tests__/session-discovery.test.ts +112 -2
- package/src/browser/browser.ts +32 -11
- package/src/browser/ref-cache.ts +16 -1
- package/src/cli/attention-wall.ts +202 -0
- package/src/cli/commands-session.ts +25 -12
- package/src/cli/commands.ts +22 -8
- package/src/cli/session.ts +78 -198
package/src/cli/session.ts
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* its own, exactly as `open` would.
|
|
24
24
|
* - Any later command with `--port N` acts on that session, which is how a
|
|
25
25
|
* concurrent caller pins the one `open` handed it.
|
|
26
|
+
* - `--session <name>` is the same pin by name: `open`/`connect` record the
|
|
27
|
+
* name on the session they start (or re-open the live one of that name),
|
|
28
|
+
* every other command acts on the live session of that name and fails
|
|
29
|
+
* when there is none. A command with no name never resolves a named
|
|
30
|
+
* session, so named sessions only ever see their own commands.
|
|
26
31
|
* - `close` ends exactly the session it resolved — one browser, one record.
|
|
27
32
|
*
|
|
28
33
|
* Split out of commands.ts, which is now the command catalogue.
|
|
@@ -31,6 +36,7 @@
|
|
|
31
36
|
import { randomUUID } from 'node:crypto';
|
|
32
37
|
import { tmpdir } from 'node:os';
|
|
33
38
|
import { join } from 'node:path';
|
|
39
|
+
import { SESSION_NAME_RE } from '../browser/ref-cache.js';
|
|
34
40
|
import type { CdpClient, ElementRef, SessionRecord } from '../index.js';
|
|
35
41
|
import { output } from './output.js';
|
|
36
42
|
import type { ParsedFlags } from './types.js';
|
|
@@ -49,6 +55,8 @@ export const session: {
|
|
|
49
55
|
sessionId: string;
|
|
50
56
|
targetId: string;
|
|
51
57
|
port: number;
|
|
58
|
+
/** `--session <name>`, or '' for the unnamed sessions. */
|
|
59
|
+
name: string;
|
|
52
60
|
refs: Map<string, ElementRef>;
|
|
53
61
|
/** Saved parent sessionId when inside an iframe — restored by `frame main`. */
|
|
54
62
|
parentSessionId: string;
|
|
@@ -57,6 +65,7 @@ export const session: {
|
|
|
57
65
|
sessionId: '',
|
|
58
66
|
targetId: '',
|
|
59
67
|
port: UNPINNED,
|
|
68
|
+
name: '',
|
|
60
69
|
refs: new Map(),
|
|
61
70
|
parentSessionId: '',
|
|
62
71
|
};
|
|
@@ -86,6 +95,29 @@ export function applySessionPortFlag(flags: ParsedFlags): void {
|
|
|
86
95
|
if (pinned !== undefined) session.port = pinned;
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Select the named session given by `--session`. Like the port pin, it only
|
|
100
|
+
* ever sets: a batched command without the flag stays on the session an
|
|
101
|
+
* earlier command in this process named.
|
|
102
|
+
*/
|
|
103
|
+
export function applySessionNameFlag(flags: ParsedFlags): void {
|
|
104
|
+
const raw = flags.session;
|
|
105
|
+
if (raw === undefined) return;
|
|
106
|
+
if (typeof raw !== 'string' || !SESSION_NAME_RE.test(raw)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Invalid session name "${String(raw)}" — use 1-64 letters, digits, '.', '_' or '-'.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
session.name = raw;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The error for a `--session` name no live browser answers to. */
|
|
115
|
+
export function noNamedSessionError(name: string): Error {
|
|
116
|
+
return new Error(
|
|
117
|
+
`No live browse session named "${name}" — start one with: monomind browse open <url> --session ${name}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
export async function getBrowser() {
|
|
90
122
|
return import('../index.js');
|
|
91
123
|
}
|
|
@@ -140,11 +172,32 @@ export function ensureSignalCleanupHandlers(): void {
|
|
|
140
172
|
//
|
|
141
173
|
// Records whose browser is gone are dropped as we pass them (self-heal): a
|
|
142
174
|
// crashed or manually-killed Chrome must not wedge every later command.
|
|
175
|
+
//
|
|
176
|
+
// `name` selects the session recorded under that `--session` name; without
|
|
177
|
+
// one, only unnamed sessions are candidates — UNLESS there is exactly one
|
|
178
|
+
// other live session, named or not (see `soleOtherLive` below): a command
|
|
179
|
+
// that named nothing still has to resolve to *something* when there is only
|
|
180
|
+
// one browser to mean, or every command run after a named-only `open` (any
|
|
181
|
+
// flag combination that leaves `session.name` unset — e.g. `snapshot`'s `-s`
|
|
182
|
+
// is `--selector`, not `--session`, see withSessionSelector) would silently
|
|
183
|
+
// miss the live named session and launch a brand-new, unrelated Chrome
|
|
184
|
+
// instead — the process this file exists to prevent (#318), just with an
|
|
185
|
+
// empty new tab standing in for the "wrong browser" (issue: named-session
|
|
186
|
+
// snapshot showed Chrome's New Tab instead of the opened page). Ambiguity
|
|
187
|
+
// (two or more other live sessions) is still refused: guessing among several
|
|
188
|
+
// named browsers is exactly the cross-session bleed #318 fixed.
|
|
143
189
|
export async function resolveLiveSession(
|
|
144
190
|
browser: Awaited<ReturnType<typeof getBrowser>>,
|
|
145
|
-
opts: { strict?: boolean } = {},
|
|
191
|
+
opts: { strict?: boolean; name?: string } = {},
|
|
146
192
|
): Promise<SessionRecord | null> {
|
|
193
|
+
const soleOtherLive: SessionRecord[] = [];
|
|
147
194
|
for (const record of await browser.listSessionRecords()) {
|
|
195
|
+
if ((record.name ?? '') !== (opts.name ?? '')) {
|
|
196
|
+
if (opts.name === undefined && soleOtherLive.length < 2 && (await cdpAnswers(record.port))) {
|
|
197
|
+
soleOtherLive.push(record);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
148
201
|
if (await cdpAnswers(record.port)) return record;
|
|
149
202
|
await browser.removeSessionRecord(record.port);
|
|
150
203
|
await browser.clearRefCache(record.port);
|
|
@@ -159,10 +212,12 @@ export async function resolveLiveSession(
|
|
|
159
212
|
);
|
|
160
213
|
}
|
|
161
214
|
}
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
215
|
+
if (opts.name === undefined && soleOtherLive.length === 1) return soleOtherLive[0];
|
|
216
|
+
// Nothing native is live (or matched). A session opened by a pre-#318 CLI
|
|
217
|
+
// in this directory may still be — it is the oldest possible candidate, so
|
|
218
|
+
// it is considered last. It predates names, so a named lookup never adopts
|
|
219
|
+
// it.
|
|
220
|
+
return opts.name ? null : adoptLegacySession(browser);
|
|
166
221
|
}
|
|
167
222
|
|
|
168
223
|
/**
|
|
@@ -227,7 +282,7 @@ async function cdpAnswers(port: number): Promise<boolean> {
|
|
|
227
282
|
*/
|
|
228
283
|
export async function launchSessionBrowser(
|
|
229
284
|
browser: Awaited<ReturnType<typeof getBrowser>>,
|
|
230
|
-
opts: { port?: number; headless: boolean },
|
|
285
|
+
opts: { port?: number; headless: boolean; name?: string },
|
|
231
286
|
): Promise<number> {
|
|
232
287
|
const port = opts.port
|
|
233
288
|
? await browser.launchBrowser({ port: opts.port, headless: opts.headless })
|
|
@@ -238,7 +293,7 @@ export async function launchSessionBrowser(
|
|
|
238
293
|
// belong to this session alone.
|
|
239
294
|
userDataDir: join(tmpdir(), `monomind-browse-${process.pid}-${randomUUID().slice(0, 8)}`),
|
|
240
295
|
});
|
|
241
|
-
await recordSession(browser, port);
|
|
296
|
+
await recordSession(browser, port, opts.name);
|
|
242
297
|
ensureSignalCleanupHandlers();
|
|
243
298
|
return port;
|
|
244
299
|
}
|
|
@@ -256,15 +311,20 @@ export async function launchSessionBrowser(
|
|
|
256
311
|
// {pid: undefined} on attach used to CLOBBER a real PID a previous `open`
|
|
257
312
|
// had persisted for this exact port, destroying the only way a later
|
|
258
313
|
// process's closeBrowser() PID-kill fallback could ever find it.
|
|
314
|
+
//
|
|
315
|
+
// `name` labels the session; without one, re-recording keeps the name the
|
|
316
|
+
// session already had, so a `--port` command does not strip it.
|
|
259
317
|
async function recordSession(
|
|
260
318
|
browser: Awaited<ReturnType<typeof getBrowser>>,
|
|
261
319
|
port: number,
|
|
320
|
+
name?: string,
|
|
262
321
|
): Promise<void> {
|
|
263
322
|
const pid = browser.getLaunchedPid(port);
|
|
264
323
|
if (pid !== undefined) {
|
|
265
324
|
await browser.saveSessionRecord(port, {
|
|
266
325
|
pid,
|
|
267
326
|
userDataDir: browser.getLaunchedUserDataDir(port),
|
|
327
|
+
name: name ?? (await browser.loadSessionRecord(port))?.name,
|
|
268
328
|
});
|
|
269
329
|
return;
|
|
270
330
|
}
|
|
@@ -277,6 +337,7 @@ async function recordSession(
|
|
|
277
337
|
launched: existing?.launched,
|
|
278
338
|
pid: existing?.pid,
|
|
279
339
|
userDataDir: existing?.userDataDir,
|
|
340
|
+
name: name ?? existing?.name,
|
|
280
341
|
});
|
|
281
342
|
}
|
|
282
343
|
|
|
@@ -290,9 +351,16 @@ export async function ensureConnected(port: number, targetId?: string) {
|
|
|
290
351
|
browser.teardownConsoleCapture(session.sessionId);
|
|
291
352
|
session.client.close();
|
|
292
353
|
}
|
|
293
|
-
// Pinned port, else the
|
|
294
|
-
// a
|
|
295
|
-
|
|
354
|
+
// Pinned port, else the named session (which must exist), else the
|
|
355
|
+
// newest live unnamed session, else a session of our own — a command run
|
|
356
|
+
// before any `open` still just works.
|
|
357
|
+
let pinned: number | undefined = port > 0 ? port : undefined;
|
|
358
|
+
if (pinned === undefined && session.name) {
|
|
359
|
+
pinned = (await resolveLiveSession(browser, { name: session.name }))?.port;
|
|
360
|
+
if (pinned === undefined) throw noNamedSessionError(session.name);
|
|
361
|
+
} else if (pinned === undefined) {
|
|
362
|
+
pinned = (await resolveLiveSession(browser))?.port;
|
|
363
|
+
}
|
|
296
364
|
session.port = await launchSessionBrowser(browser, { port: pinned, headless: true });
|
|
297
365
|
const conn = await browser.connectToTarget(session.port, targetId);
|
|
298
366
|
session.client = conn.client;
|
|
@@ -341,194 +409,6 @@ export async function hydrateRefsFromCache(
|
|
|
341
409
|
}
|
|
342
410
|
}
|
|
343
411
|
|
|
344
|
-
// URL patterns that signal an unambiguous login/auth wall.
|
|
345
|
-
// Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
|
|
346
|
-
// (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
|
|
347
|
-
// DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
|
|
348
|
-
const ATTENTION_URL_RE =
|
|
349
|
-
/\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
|
|
350
|
-
|
|
351
|
-
export async function detectAttentionNeeded(
|
|
352
|
-
client: CdpClient,
|
|
353
|
-
sessionId: string,
|
|
354
|
-
url: string,
|
|
355
|
-
): Promise<'login' | 'captcha' | null> {
|
|
356
|
-
if (ATTENTION_URL_RE.test(url)) return 'login';
|
|
357
|
-
try {
|
|
358
|
-
const hasPassword = await client
|
|
359
|
-
.send<{ result: { value: boolean } }>(
|
|
360
|
-
'Runtime.evaluate',
|
|
361
|
-
{
|
|
362
|
-
expression: '!!document.querySelector("input[type=password]")',
|
|
363
|
-
returnByValue: true,
|
|
364
|
-
},
|
|
365
|
-
sessionId,
|
|
366
|
-
)
|
|
367
|
-
.then((r) => r.result?.value === true)
|
|
368
|
-
.catch(() => false);
|
|
369
|
-
if (hasPassword) return 'login';
|
|
370
|
-
|
|
371
|
-
const hasCaptcha = await client
|
|
372
|
-
.send<{ result: { value: boolean } }>(
|
|
373
|
-
'Runtime.evaluate',
|
|
374
|
-
{
|
|
375
|
-
expression:
|
|
376
|
-
'!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
|
|
377
|
-
returnByValue: true,
|
|
378
|
-
},
|
|
379
|
-
sessionId,
|
|
380
|
-
)
|
|
381
|
-
.then((r) => r.result?.value === true)
|
|
382
|
-
.catch(() => false);
|
|
383
|
-
if (hasCaptcha) return 'captcha';
|
|
384
|
-
} catch {
|
|
385
|
-
/* ignore CDP errors */
|
|
386
|
-
}
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
export function waitForEnter(): Promise<void> {
|
|
391
|
-
if (!process.stdin.isTTY) {
|
|
392
|
-
// Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
|
|
393
|
-
// Auto-continue after a short grace period so automation isn't blocked.
|
|
394
|
-
output.printWarning(
|
|
395
|
-
'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
|
|
396
|
-
);
|
|
397
|
-
return new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
398
|
-
}
|
|
399
|
-
return new Promise((resolve) => {
|
|
400
|
-
process.stdin.resume();
|
|
401
|
-
process.stdin.setEncoding('utf8');
|
|
402
|
-
const onData = () => {
|
|
403
|
-
process.stdin.pause();
|
|
404
|
-
process.stdin.off('data', onData);
|
|
405
|
-
resolve();
|
|
406
|
-
};
|
|
407
|
-
process.stdin.once('data', onData);
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
export async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
412
|
-
const browser = await getBrowser();
|
|
413
|
-
|
|
414
|
-
// Snapshot cookies from current headless session before closing
|
|
415
|
-
let savedCookies: unknown[] = [];
|
|
416
|
-
if (session.client && session.sessionId) {
|
|
417
|
-
try {
|
|
418
|
-
savedCookies = await browser.getCookies(session.client, session.sessionId);
|
|
419
|
-
} catch {
|
|
420
|
-
/* ignore */
|
|
421
|
-
}
|
|
422
|
-
browser.teardownRouteInterception(session.sessionId);
|
|
423
|
-
browser.stopRequestCapture(session.sessionId);
|
|
424
|
-
browser.teardownDialogHandling(session.sessionId);
|
|
425
|
-
browser.teardownConsoleCapture(session.sessionId);
|
|
426
|
-
session.client.close();
|
|
427
|
-
session.client = null;
|
|
428
|
-
session.sessionId = '';
|
|
429
|
-
session.parentSessionId = '';
|
|
430
|
-
session.targetId = '';
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Kernel-assigned free port and a profile of its own, so this temporary
|
|
434
|
-
// headed window cannot collide with any other session (#318) — a
|
|
435
|
-
// neighbouring port (port + 10) could well be another session's.
|
|
436
|
-
let headedPort = 0;
|
|
437
|
-
try {
|
|
438
|
-
headedPort = await browser.launchBrowser({
|
|
439
|
-
port: 0,
|
|
440
|
-
headless: false,
|
|
441
|
-
userDataDir: join(
|
|
442
|
-
tmpdir(),
|
|
443
|
-
`monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
|
|
444
|
-
),
|
|
445
|
-
});
|
|
446
|
-
} catch (err) {
|
|
447
|
-
// Headed launch failed (no display, locked down env). Restore headless and rethrow.
|
|
448
|
-
session.port = await browser.launchBrowser({ port, headless: true });
|
|
449
|
-
const fallback = await browser.connectToTarget(session.port);
|
|
450
|
-
session.client = fallback.client;
|
|
451
|
-
session.sessionId = fallback.sessionId;
|
|
452
|
-
session.targetId = fallback.target.id;
|
|
453
|
-
session.refs = new Map();
|
|
454
|
-
throw new Error(
|
|
455
|
-
`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
const conn = await browser.connectToTarget(headedPort);
|
|
459
|
-
session.client = conn.client;
|
|
460
|
-
session.sessionId = conn.sessionId;
|
|
461
|
-
session.targetId = conn.target.id;
|
|
462
|
-
session.refs = new Map();
|
|
463
|
-
|
|
464
|
-
if (savedCookies.length) {
|
|
465
|
-
try {
|
|
466
|
-
await browser.setCookies(
|
|
467
|
-
session.client,
|
|
468
|
-
session.sessionId,
|
|
469
|
-
savedCookies as Parameters<typeof browser.setCookies>[2],
|
|
470
|
-
);
|
|
471
|
-
} catch {
|
|
472
|
-
/* ignore */
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
await browser.openUrl(session.client, session.sessionId, url);
|
|
476
|
-
|
|
477
|
-
output.printInfo(
|
|
478
|
-
'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
|
|
479
|
-
);
|
|
480
|
-
await waitForEnter();
|
|
481
|
-
|
|
482
|
-
// Capture post-auth cookies
|
|
483
|
-
const authCookies = await browser
|
|
484
|
-
.getCookies(session.client, session.sessionId)
|
|
485
|
-
.catch(() => [] as unknown[]);
|
|
486
|
-
const authLocalStorage = await browser
|
|
487
|
-
.getLocalStorage(session.client, session.sessionId)
|
|
488
|
-
.catch(() => ({}) as Record<string, string>);
|
|
489
|
-
|
|
490
|
-
// Close headed session — actually terminate the underlying Chrome process
|
|
491
|
-
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
492
|
-
// connection, so the visible authenticated window doesn't linger forever.
|
|
493
|
-
browser.teardownRouteInterception(session.sessionId);
|
|
494
|
-
browser.stopRequestCapture(session.sessionId);
|
|
495
|
-
browser.teardownDialogHandling(session.sessionId);
|
|
496
|
-
browser.teardownConsoleCapture(session.sessionId);
|
|
497
|
-
await browser.closeBrowser(session.client, headedPort);
|
|
498
|
-
session.client.close();
|
|
499
|
-
session.client = null;
|
|
500
|
-
session.sessionId = '';
|
|
501
|
-
session.parentSessionId = '';
|
|
502
|
-
session.targetId = '';
|
|
503
|
-
|
|
504
|
-
// Relaunch headless and restore session
|
|
505
|
-
session.port = await browser.launchBrowser({ port, headless: true });
|
|
506
|
-
const headlessConn = await browser.connectToTarget(session.port);
|
|
507
|
-
session.client = headlessConn.client;
|
|
508
|
-
session.sessionId = headlessConn.sessionId;
|
|
509
|
-
session.targetId = headlessConn.target.id;
|
|
510
|
-
session.refs = new Map();
|
|
511
|
-
|
|
512
|
-
if (authCookies.length) {
|
|
513
|
-
try {
|
|
514
|
-
await browser.setCookies(
|
|
515
|
-
session.client,
|
|
516
|
-
session.sessionId,
|
|
517
|
-
authCookies as Parameters<typeof browser.setCookies>[2],
|
|
518
|
-
);
|
|
519
|
-
} catch {
|
|
520
|
-
/* ignore */
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (authLocalStorage && Object.keys(authLocalStorage).length) {
|
|
524
|
-
try {
|
|
525
|
-
await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
|
|
526
|
-
} catch {
|
|
527
|
-
/* ignore */
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
412
|
// Cap on printed output so a command cannot flood a terminal or an agent's
|
|
533
413
|
// context with a whole page. 0 disables truncation.
|
|
534
414
|
export const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
|