@monoes/monobrowse 1.0.22 → 1.0.24
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__/session-discovery.test.js +81 -1
- package/dist/src/__tests__/session-discovery.test.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__/session-discovery.test.ts +112 -2
- 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/package.json
CHANGED
|
@@ -18,7 +18,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
21
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
22
22
|
import { CHROME_EXECUTABLES } from '../browser/types.js';
|
|
23
23
|
|
|
24
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -100,7 +100,51 @@ afterEach(async () => {
|
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Start Chrome once, outside any trial, and let it finish. A runner's first
|
|
105
|
+
* Chrome start pays for paging the binary and its libraries in and building
|
|
106
|
+
* the font cache; two of them at once in trial 1 took longer than `open`'s
|
|
107
|
+
* 10 s CDP wait on CI, while every later trial passed. `--dump-dom` exits on
|
|
108
|
+
* its own once the page loads, so this has no timeout of `open`'s to beat.
|
|
109
|
+
*/
|
|
110
|
+
async function warmUpChrome(): Promise<void> {
|
|
111
|
+
let found: string;
|
|
112
|
+
try {
|
|
113
|
+
found = execFileSync('which', ['google-chrome', 'chromium-browser', 'chromium'], {
|
|
114
|
+
encoding: 'utf8',
|
|
115
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// `which` exits non-zero when any one name is missing; the rest still print.
|
|
119
|
+
found = String((err as { stdout?: string }).stdout ?? '');
|
|
120
|
+
}
|
|
121
|
+
const chrome = CHROME_EXECUTABLES.find((p) => existsSync(p)) ?? found.split('\n')[0];
|
|
122
|
+
if (!chrome) return;
|
|
123
|
+
const profile = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-warmup-'));
|
|
124
|
+
try {
|
|
125
|
+
await new Promise<void>((resolve) => {
|
|
126
|
+
execFile(
|
|
127
|
+
chrome,
|
|
128
|
+
[
|
|
129
|
+
'--headless=new',
|
|
130
|
+
`--user-data-dir=${profile}`,
|
|
131
|
+
'--no-first-run',
|
|
132
|
+
...(process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage'] : []),
|
|
133
|
+
'--dump-dom',
|
|
134
|
+
'about:blank',
|
|
135
|
+
],
|
|
136
|
+
{ timeout: 60_000 },
|
|
137
|
+
() => resolve(),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
describe.skipIf(!runnable)('#318 concurrent browse sessions (real processes)', () => {
|
|
146
|
+
beforeAll(warmUpChrome, 90_000);
|
|
147
|
+
|
|
104
148
|
for (const trial of [1, 2, 3, 4, 5]) {
|
|
105
149
|
it(`trial ${trial}: two bare \`open\` calls each get their own browser, and each close kills only its own`, async () => {
|
|
106
150
|
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-'));
|
|
@@ -231,3 +275,64 @@ describe.skipIf(!runnable)('#318 adoption of a pre-#318 session (real processes)
|
|
|
231
275
|
expect(await cdpAlive(legacyPort)).toBe(false);
|
|
232
276
|
}, 90_000);
|
|
233
277
|
});
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* `--session <name>` is documented (browse-qa, browse-electron skills) as a
|
|
281
|
+
* way to drive several browsers side by side. It used to be a no-op outside
|
|
282
|
+
* `open`, where it only restored saved state and failed when none existed —
|
|
283
|
+
* AFTER launching a browser — so every later command silently acted on
|
|
284
|
+
* whichever session was newest. Real processes, because the bug is in what a
|
|
285
|
+
* later, separate CLI invocation resolves.
|
|
286
|
+
*/
|
|
287
|
+
describe.skipIf(!runnable)('--session named sessions (real processes)', () => {
|
|
288
|
+
beforeAll(warmUpChrome, 90_000);
|
|
289
|
+
|
|
290
|
+
it('two named sessions stay apart, a bare command never lands on them, and an unknown name fails', async () => {
|
|
291
|
+
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-named-'));
|
|
292
|
+
dirs.push(root);
|
|
293
|
+
const pageA = join(root, 'a.html');
|
|
294
|
+
const pageB = join(root, 'b.html');
|
|
295
|
+
await writeFile(pageA, '<!doctype html><title>Page A</title><h1>a</h1>');
|
|
296
|
+
await writeFile(pageB, '<!doctype html><title>Page B</title><h1>b</h1>');
|
|
297
|
+
|
|
298
|
+
const openA = await runCli(['open', `file://${pageA}`, '--session', 'ref'], root, root);
|
|
299
|
+
expect(openA.code, `open ref failed: ${openA.stdout}${openA.stderr}`).toBe(0);
|
|
300
|
+
const openB = await runCli(['open', `file://${pageB}`, '--session', 'build'], root, root);
|
|
301
|
+
expect(openB.code, `open build failed: ${openB.stdout}${openB.stderr}`).toBe(0);
|
|
302
|
+
const portA = reportedPort(openA);
|
|
303
|
+
const portB = reportedPort(openB);
|
|
304
|
+
expect(portA).not.toBeNull();
|
|
305
|
+
expect(portA).not.toBe(portB);
|
|
306
|
+
|
|
307
|
+
// 'build' is the newest session, yet --session ref reaches 'ref'.
|
|
308
|
+
const titleA = await runCli(['get', 'title', '--session', 'ref'], root, root);
|
|
309
|
+
expect(titleA.code, `get title ref failed: ${titleA.stdout}${titleA.stderr}`).toBe(0);
|
|
310
|
+
expect(titleA.stdout).toContain('Page A');
|
|
311
|
+
const titleB = await runCli(['get', 'title', '--session', 'build'], root, root);
|
|
312
|
+
expect(titleB.stdout).toContain('Page B');
|
|
313
|
+
|
|
314
|
+
// Re-opening a name navigates that session's own browser.
|
|
315
|
+
const reopen = await runCli(['open', `file://${pageB}`, '--session', 'ref'], root, root);
|
|
316
|
+
expect(reopen.code, `re-open ref failed: ${reopen.stdout}${reopen.stderr}`).toBe(0);
|
|
317
|
+
expect(reportedPort(reopen)).toBe(portA);
|
|
318
|
+
|
|
319
|
+
// An unknown name is an error, not a silent fallback to the newest session.
|
|
320
|
+
const unknown = await runCli(['get', 'title', '--session', 'nope'], root, root);
|
|
321
|
+
expect(unknown.code).not.toBe(0);
|
|
322
|
+
expect(`${unknown.stdout}${unknown.stderr}`).toMatch(/No live browse session named "nope"/);
|
|
323
|
+
|
|
324
|
+
// A bare command starts its own browser instead of joining a named one.
|
|
325
|
+
const bare = await runCli(['get', 'title'], root, root);
|
|
326
|
+
expect(bare.code, `bare get title failed: ${bare.stdout}${bare.stderr}`).toBe(0);
|
|
327
|
+
expect(bare.stdout).not.toContain('Page');
|
|
328
|
+
|
|
329
|
+
// close --session ends exactly that session.
|
|
330
|
+
const closeA = await runCli(['close', '--session', 'ref'], root, root);
|
|
331
|
+
expect(closeA.code, `close ref failed: ${closeA.stdout}${closeA.stderr}`).toBe(0);
|
|
332
|
+
expect(await cdpAlive(portA!)).toBe(false);
|
|
333
|
+
expect(await cdpAlive(portB!)).toBe(true);
|
|
334
|
+
await runCli(['close', '--session', 'build'], root, root);
|
|
335
|
+
expect(await cdpAlive(portB!)).toBe(false);
|
|
336
|
+
await runCli(['close'], root, root);
|
|
337
|
+
}, 120_000);
|
|
338
|
+
});
|
|
@@ -30,8 +30,14 @@ afterEach(async () => {
|
|
|
30
30
|
async function load() {
|
|
31
31
|
vi.resetModules();
|
|
32
32
|
const store = await import('../browser/ref-cache.js');
|
|
33
|
-
const {
|
|
34
|
-
|
|
33
|
+
const {
|
|
34
|
+
resolveLiveSession,
|
|
35
|
+
adoptLegacySession,
|
|
36
|
+
pinnedPort,
|
|
37
|
+
applySessionPortFlag,
|
|
38
|
+
applySessionNameFlag,
|
|
39
|
+
session,
|
|
40
|
+
} = await import('../cli/session.js');
|
|
35
41
|
// The slice of the browser namespace resolveLiveSession uses.
|
|
36
42
|
const browser = {
|
|
37
43
|
listSessionRecords: store.listSessionRecords,
|
|
@@ -49,6 +55,7 @@ async function load() {
|
|
|
49
55
|
adoptLegacySession,
|
|
50
56
|
pinnedPort,
|
|
51
57
|
applySessionPortFlag,
|
|
58
|
+
applySessionNameFlag,
|
|
52
59
|
session,
|
|
53
60
|
};
|
|
54
61
|
}
|
|
@@ -257,3 +264,106 @@ describe('#318 --port session selector', () => {
|
|
|
257
264
|
expect(session.port).toBe(41111);
|
|
258
265
|
});
|
|
259
266
|
});
|
|
267
|
+
|
|
268
|
+
describe('--session names a live session', () => {
|
|
269
|
+
/** Record sessions oldest → newest, so newest-first order is deterministic. */
|
|
270
|
+
async function recordInOrder(
|
|
271
|
+
store: Awaited<ReturnType<typeof load>>['store'],
|
|
272
|
+
...records: Array<{ port: number; name?: string }>
|
|
273
|
+
) {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const clock = vi.spyOn(Date, 'now');
|
|
276
|
+
for (const [i, r] of records.entries()) {
|
|
277
|
+
clock.mockReturnValue(now - (records.length - i) * 1_000);
|
|
278
|
+
await store.saveSessionRecord(r.port, { pid: 10 + i, name: r.name });
|
|
279
|
+
}
|
|
280
|
+
clock.mockRestore();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
it('the record keeps its name', async () => {
|
|
284
|
+
const { store } = await load();
|
|
285
|
+
await store.saveSessionRecord(41111, { pid: 11, name: 'ref' });
|
|
286
|
+
expect((await store.loadSessionRecord(41111))?.name).toBe('ref');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('resolves the live session with that name, not the newest one', async () => {
|
|
290
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
291
|
+
await recordInOrder(
|
|
292
|
+
store,
|
|
293
|
+
{ port: 41111, name: 'ref' },
|
|
294
|
+
{ port: 42222, name: 'build' },
|
|
295
|
+
{ port: 43333 },
|
|
296
|
+
);
|
|
297
|
+
onlyLive(41111, 42222, 43333);
|
|
298
|
+
|
|
299
|
+
expect((await resolveLiveSession(browser, { name: 'ref' }))?.port).toBe(41111);
|
|
300
|
+
expect((await resolveLiveSession(browser, { name: 'build' }))?.port).toBe(42222);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('an unknown name resolves to none instead of falling back to another session', async () => {
|
|
304
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
305
|
+
await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'build' });
|
|
306
|
+
onlyLive(41111, 42222);
|
|
307
|
+
|
|
308
|
+
expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('a command with no name never lands on a named session', async () => {
|
|
312
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
313
|
+
await recordInOrder(store, { port: 41111 }, { port: 42222, name: 'ref' });
|
|
314
|
+
onlyLive(41111, 42222);
|
|
315
|
+
|
|
316
|
+
expect((await resolveLiveSession(browser))?.port).toBe(41111);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('a dead named session is dropped, and the name resolves to none', async () => {
|
|
320
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
321
|
+
await recordInOrder(store, { port: 41111, name: 'ref' });
|
|
322
|
+
onlyLive();
|
|
323
|
+
|
|
324
|
+
expect(await resolveLiveSession(browser, { name: 'ref' })).toBeNull();
|
|
325
|
+
expect(await store.loadSessionRecord(41111)).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Regression: a command whose flags never set `session.name` (e.g.
|
|
329
|
+
// `snapshot -s <name>` — `-s` is `--selector` on snapshot, not
|
|
330
|
+
// `--session`, see withSessionSelector in cli/commands.ts) used to find
|
|
331
|
+
// NO live session at all once the only live session was named, so
|
|
332
|
+
// ensureConnected silently launched a brand-new, unrelated headless
|
|
333
|
+
// Chrome — its blank "New Tab" is what a following snapshot showed
|
|
334
|
+
// instead of the page the named session had actually opened. With only
|
|
335
|
+
// one live session, named or not, a nameless lookup must still resolve to
|
|
336
|
+
// it: there is nothing else it could mean.
|
|
337
|
+
it('a command with no name resolves the sole live session even if it is named', async () => {
|
|
338
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
339
|
+
await recordInOrder(store, { port: 41111, name: 'ctrl' });
|
|
340
|
+
onlyLive(41111);
|
|
341
|
+
|
|
342
|
+
expect((await resolveLiveSession(browser))?.port).toBe(41111);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// But guessing between two DIFFERENT named sessions would reintroduce the
|
|
346
|
+
// exact cross-session bleed #318 fixed — refuse instead of picking one.
|
|
347
|
+
it('a command with no name refuses to guess between two live named sessions', async () => {
|
|
348
|
+
const { store, browser, resolveLiveSession } = await load();
|
|
349
|
+
await recordInOrder(store, { port: 41111, name: 'alpha' }, { port: 42222, name: 'beta' });
|
|
350
|
+
onlyLive(41111, 42222);
|
|
351
|
+
|
|
352
|
+
expect(await resolveLiveSession(browser)).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('--session sets the process session name; a missing flag leaves it alone', async () => {
|
|
356
|
+
const { applySessionNameFlag, session } = await load();
|
|
357
|
+
applySessionNameFlag({ _: [], session: 'ref' });
|
|
358
|
+
expect(session.name).toBe('ref');
|
|
359
|
+
applySessionNameFlag({ _: [] });
|
|
360
|
+
expect(session.name).toBe('ref');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('rejects a session name that is not a plain identifier', async () => {
|
|
364
|
+
const { applySessionNameFlag } = await load();
|
|
365
|
+
for (const bad of ['', '../x', 'a b', 'x'.repeat(65)]) {
|
|
366
|
+
expect(() => applySessionNameFlag({ _: [], session: bad })).toThrow(/Invalid session name/);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
package/src/browser/ref-cache.ts
CHANGED
|
@@ -119,8 +119,14 @@ export interface SessionRecord {
|
|
|
119
119
|
pid?: number;
|
|
120
120
|
userDataDir?: string;
|
|
121
121
|
savedAt?: number;
|
|
122
|
+
/** Set by `--session <name>`: only commands naming it resolve this session,
|
|
123
|
+
* and commands naming none never land on it. */
|
|
124
|
+
name?: string;
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** A `--session` name: a plain identifier, safe as a label and in messages. */
|
|
128
|
+
export const SESSION_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
129
|
+
|
|
124
130
|
/**
|
|
125
131
|
* Record a browse session under its own port, so concurrent sessions in the
|
|
126
132
|
* same working directory coexist instead of overwriting one shared handle.
|
|
@@ -132,7 +138,13 @@ export interface SessionRecord {
|
|
|
132
138
|
*/
|
|
133
139
|
export async function saveSessionRecord(
|
|
134
140
|
port: number,
|
|
135
|
-
opts?: {
|
|
141
|
+
opts?: {
|
|
142
|
+
launched?: boolean;
|
|
143
|
+
pid?: number;
|
|
144
|
+
userDataDir?: string;
|
|
145
|
+
savedAt?: number;
|
|
146
|
+
name?: string;
|
|
147
|
+
},
|
|
136
148
|
): Promise<void> {
|
|
137
149
|
try {
|
|
138
150
|
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
@@ -150,6 +162,7 @@ export async function saveSessionRecord(
|
|
|
150
162
|
pid: opts?.pid,
|
|
151
163
|
userDataDir: opts?.userDataDir,
|
|
152
164
|
savedAt: opts?.savedAt ?? Date.now(),
|
|
165
|
+
name: opts?.name,
|
|
153
166
|
}),
|
|
154
167
|
);
|
|
155
168
|
} catch {
|
|
@@ -235,6 +248,7 @@ function parseSessionRecord(raw: string): SessionRecord | null {
|
|
|
235
248
|
pid?: unknown;
|
|
236
249
|
userDataDir?: unknown;
|
|
237
250
|
savedAt?: unknown;
|
|
251
|
+
name?: unknown;
|
|
238
252
|
};
|
|
239
253
|
if (
|
|
240
254
|
typeof data.port !== 'number' ||
|
|
@@ -253,5 +267,6 @@ function parseSessionRecord(raw: string): SessionRecord | null {
|
|
|
253
267
|
: undefined,
|
|
254
268
|
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
255
269
|
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
270
|
+
name: typeof data.name === 'string' && SESSION_NAME_RE.test(data.name) ? data.name : undefined,
|
|
256
271
|
};
|
|
257
272
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login / CAPTCHA walls — how `open` notices one, and the round trip to a
|
|
3
|
+
* visible browser window that lets a person get past it before the session
|
|
4
|
+
* carries on headless with the cookies and localStorage they earned.
|
|
5
|
+
*
|
|
6
|
+
* Split out of session.ts, which keeps the session runtime itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { CdpClient } from '../index.js';
|
|
13
|
+
import { output } from './output.js';
|
|
14
|
+
import { getBrowser, session } from './session.js';
|
|
15
|
+
|
|
16
|
+
// URL patterns that signal an unambiguous login/auth wall.
|
|
17
|
+
// Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
|
|
18
|
+
// (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
|
|
19
|
+
// DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
|
|
20
|
+
const ATTENTION_URL_RE =
|
|
21
|
+
/\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
|
|
22
|
+
|
|
23
|
+
export async function detectAttentionNeeded(
|
|
24
|
+
client: CdpClient,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
url: string,
|
|
27
|
+
): Promise<'login' | 'captcha' | null> {
|
|
28
|
+
if (ATTENTION_URL_RE.test(url)) return 'login';
|
|
29
|
+
try {
|
|
30
|
+
const hasPassword = await client
|
|
31
|
+
.send<{ result: { value: boolean } }>(
|
|
32
|
+
'Runtime.evaluate',
|
|
33
|
+
{
|
|
34
|
+
expression: '!!document.querySelector("input[type=password]")',
|
|
35
|
+
returnByValue: true,
|
|
36
|
+
},
|
|
37
|
+
sessionId,
|
|
38
|
+
)
|
|
39
|
+
.then((r) => r.result?.value === true)
|
|
40
|
+
.catch(() => false);
|
|
41
|
+
if (hasPassword) return 'login';
|
|
42
|
+
|
|
43
|
+
const hasCaptcha = await client
|
|
44
|
+
.send<{ result: { value: boolean } }>(
|
|
45
|
+
'Runtime.evaluate',
|
|
46
|
+
{
|
|
47
|
+
expression:
|
|
48
|
+
'!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
|
|
49
|
+
returnByValue: true,
|
|
50
|
+
},
|
|
51
|
+
sessionId,
|
|
52
|
+
)
|
|
53
|
+
.then((r) => r.result?.value === true)
|
|
54
|
+
.catch(() => false);
|
|
55
|
+
if (hasCaptcha) return 'captcha';
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore CDP errors */
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function waitForEnter(): Promise<void> {
|
|
63
|
+
if (!process.stdin.isTTY) {
|
|
64
|
+
// Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
|
|
65
|
+
// Auto-continue after a short grace period so automation isn't blocked.
|
|
66
|
+
output.printWarning(
|
|
67
|
+
'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
|
|
68
|
+
);
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
process.stdin.resume();
|
|
73
|
+
process.stdin.setEncoding('utf8');
|
|
74
|
+
const onData = () => {
|
|
75
|
+
process.stdin.pause();
|
|
76
|
+
process.stdin.off('data', onData);
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
process.stdin.once('data', onData);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
84
|
+
const browser = await getBrowser();
|
|
85
|
+
|
|
86
|
+
// Snapshot cookies from current headless session before closing
|
|
87
|
+
let savedCookies: unknown[] = [];
|
|
88
|
+
if (session.client && session.sessionId) {
|
|
89
|
+
try {
|
|
90
|
+
savedCookies = await browser.getCookies(session.client, session.sessionId);
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
browser.teardownRouteInterception(session.sessionId);
|
|
95
|
+
browser.stopRequestCapture(session.sessionId);
|
|
96
|
+
browser.teardownDialogHandling(session.sessionId);
|
|
97
|
+
browser.teardownConsoleCapture(session.sessionId);
|
|
98
|
+
session.client.close();
|
|
99
|
+
session.client = null;
|
|
100
|
+
session.sessionId = '';
|
|
101
|
+
session.parentSessionId = '';
|
|
102
|
+
session.targetId = '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Kernel-assigned free port and a profile of its own, so this temporary
|
|
106
|
+
// headed window cannot collide with any other session (#318) — a
|
|
107
|
+
// neighbouring port (port + 10) could well be another session's.
|
|
108
|
+
let headedPort = 0;
|
|
109
|
+
try {
|
|
110
|
+
headedPort = await browser.launchBrowser({
|
|
111
|
+
port: 0,
|
|
112
|
+
headless: false,
|
|
113
|
+
userDataDir: join(
|
|
114
|
+
tmpdir(),
|
|
115
|
+
`monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Headed launch failed (no display, locked down env). Restore headless and rethrow.
|
|
120
|
+
session.port = await browser.launchBrowser({ port, headless: true });
|
|
121
|
+
const fallback = await browser.connectToTarget(session.port);
|
|
122
|
+
session.client = fallback.client;
|
|
123
|
+
session.sessionId = fallback.sessionId;
|
|
124
|
+
session.targetId = fallback.target.id;
|
|
125
|
+
session.refs = new Map();
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const conn = await browser.connectToTarget(headedPort);
|
|
131
|
+
session.client = conn.client;
|
|
132
|
+
session.sessionId = conn.sessionId;
|
|
133
|
+
session.targetId = conn.target.id;
|
|
134
|
+
session.refs = new Map();
|
|
135
|
+
|
|
136
|
+
if (savedCookies.length) {
|
|
137
|
+
try {
|
|
138
|
+
await browser.setCookies(
|
|
139
|
+
session.client,
|
|
140
|
+
session.sessionId,
|
|
141
|
+
savedCookies as Parameters<typeof browser.setCookies>[2],
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await browser.openUrl(session.client, session.sessionId, url);
|
|
148
|
+
|
|
149
|
+
output.printInfo(
|
|
150
|
+
'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
|
|
151
|
+
);
|
|
152
|
+
await waitForEnter();
|
|
153
|
+
|
|
154
|
+
// Capture post-auth cookies
|
|
155
|
+
const authCookies = await browser
|
|
156
|
+
.getCookies(session.client, session.sessionId)
|
|
157
|
+
.catch(() => [] as unknown[]);
|
|
158
|
+
const authLocalStorage = await browser
|
|
159
|
+
.getLocalStorage(session.client, session.sessionId)
|
|
160
|
+
.catch(() => ({}) as Record<string, string>);
|
|
161
|
+
|
|
162
|
+
// Close headed session — actually terminate the underlying Chrome process
|
|
163
|
+
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
164
|
+
// connection, so the visible authenticated window doesn't linger forever.
|
|
165
|
+
browser.teardownRouteInterception(session.sessionId);
|
|
166
|
+
browser.stopRequestCapture(session.sessionId);
|
|
167
|
+
browser.teardownDialogHandling(session.sessionId);
|
|
168
|
+
browser.teardownConsoleCapture(session.sessionId);
|
|
169
|
+
await browser.closeBrowser(session.client, headedPort);
|
|
170
|
+
session.client.close();
|
|
171
|
+
session.client = null;
|
|
172
|
+
session.sessionId = '';
|
|
173
|
+
session.parentSessionId = '';
|
|
174
|
+
session.targetId = '';
|
|
175
|
+
|
|
176
|
+
// Relaunch headless and restore session
|
|
177
|
+
session.port = await browser.launchBrowser({ port, headless: true });
|
|
178
|
+
const headlessConn = await browser.connectToTarget(session.port);
|
|
179
|
+
session.client = headlessConn.client;
|
|
180
|
+
session.sessionId = headlessConn.sessionId;
|
|
181
|
+
session.targetId = headlessConn.target.id;
|
|
182
|
+
session.refs = new Map();
|
|
183
|
+
|
|
184
|
+
if (authCookies.length) {
|
|
185
|
+
try {
|
|
186
|
+
await browser.setCookies(
|
|
187
|
+
session.client,
|
|
188
|
+
session.sessionId,
|
|
189
|
+
authCookies as Parameters<typeof browser.setCookies>[2],
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (authLocalStorage && Object.keys(authLocalStorage).length) {
|
|
196
|
+
try {
|
|
197
|
+
await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
|
|
198
|
+
} catch {
|
|
199
|
+
/* ignore */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -8,17 +8,16 @@
|
|
|
8
8
|
* differently: it must never kill a Chrome it did not launch.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { detectAttentionNeeded, switchToHeaded } from './attention-wall.js';
|
|
11
12
|
import { output } from './output.js';
|
|
12
13
|
import {
|
|
13
14
|
adoptLegacySession,
|
|
14
|
-
detectAttentionNeeded,
|
|
15
15
|
ensureConnected,
|
|
16
16
|
getBrowser,
|
|
17
17
|
launchSessionBrowser,
|
|
18
18
|
pinnedPort,
|
|
19
19
|
resolveLiveSession,
|
|
20
20
|
session,
|
|
21
|
-
switchToHeaded,
|
|
22
21
|
} from './session.js';
|
|
23
22
|
import type { Command, CommandContext, CommandResult } from './types.js';
|
|
24
23
|
|
|
@@ -39,7 +38,13 @@ export const openCommand: Command = {
|
|
|
39
38
|
description: 'Force visible browser window',
|
|
40
39
|
default: false,
|
|
41
40
|
},
|
|
42
|
-
{
|
|
41
|
+
{
|
|
42
|
+
name: 'session',
|
|
43
|
+
short: 's',
|
|
44
|
+
type: 'string',
|
|
45
|
+
description:
|
|
46
|
+
'Named session: re-open the live one with this name or start it, and restore saved state of that name if any',
|
|
47
|
+
},
|
|
43
48
|
{ name: 'state', type: 'string', description: 'State file to load' },
|
|
44
49
|
],
|
|
45
50
|
action: async (ctx: CommandContext): Promise<CommandResult> => {
|
|
@@ -48,10 +53,14 @@ export const openCommand: Command = {
|
|
|
48
53
|
|
|
49
54
|
// No --port means "start a session of my own on a free port" (#318) —
|
|
50
55
|
// never "join whatever is on 9222", which is what made two uncoordinated
|
|
51
|
-
// `open` calls race for one browser and one profile directory.
|
|
52
|
-
|
|
53
|
-
const forceHeaded = ctx.flags.headed as boolean;
|
|
56
|
+
// `open` calls race for one browser and one profile directory. A
|
|
57
|
+
// --session name re-opens the live session of that name, if there is one.
|
|
54
58
|
const browser = await getBrowser();
|
|
59
|
+
const name = session.name || undefined;
|
|
60
|
+
const port =
|
|
61
|
+
pinnedPort(ctx.flags) ??
|
|
62
|
+
(name ? (await resolveLiveSession(browser, { name }))?.port : undefined);
|
|
63
|
+
const forceHeaded = ctx.flags.headed as boolean;
|
|
55
64
|
|
|
56
65
|
if (session.client) {
|
|
57
66
|
const prevSid = session.sessionId;
|
|
@@ -91,7 +100,7 @@ export const openCommand: Command = {
|
|
|
91
100
|
|
|
92
101
|
// Starts this session's own browser (and records it) unless --port named
|
|
93
102
|
// one to attach to — see the session rule in session.ts.
|
|
94
|
-
session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
|
|
103
|
+
session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded, name });
|
|
95
104
|
const conn = await browser.connectToTarget(session.port);
|
|
96
105
|
session.client = conn.client;
|
|
97
106
|
session.sessionId = conn.sessionId;
|
|
@@ -107,8 +116,10 @@ export const openCommand: Command = {
|
|
|
107
116
|
}
|
|
108
117
|
if (ctx.flags.state) {
|
|
109
118
|
await browser.loadStateFile(session.client, session.sessionId, ctx.flags.state as string);
|
|
110
|
-
} else if (
|
|
111
|
-
|
|
119
|
+
} else if (name && (await browser.listSessions()).includes(name)) {
|
|
120
|
+
// Saved state of the same name (`state save <name>`) is restored; a
|
|
121
|
+
// name with none is simply a fresh named session.
|
|
122
|
+
await browser.loadSession(session.client, session.sessionId, name);
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
await browser.openUrl(session.client, session.sessionId, url);
|
|
@@ -228,7 +239,7 @@ export const closeCommand: Command = {
|
|
|
228
239
|
port: pinned,
|
|
229
240
|
launched: true,
|
|
230
241
|
})
|
|
231
|
-
: await resolveLiveSession(browser, { strict: false });
|
|
242
|
+
: await resolveLiveSession(browser, { strict: false, name: session.name || undefined });
|
|
232
243
|
if (persisted?.launched) {
|
|
233
244
|
try {
|
|
234
245
|
const conn = await browser.connectToTarget(persisted.port);
|
|
@@ -271,7 +282,9 @@ export const closeCommand: Command = {
|
|
|
271
282
|
output.printInfo(
|
|
272
283
|
persisted
|
|
273
284
|
? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
|
|
274
|
-
:
|
|
285
|
+
: session.name
|
|
286
|
+
? `No live browse session named "${session.name}"`
|
|
287
|
+
: 'No active browser session',
|
|
275
288
|
);
|
|
276
289
|
}
|
|
277
290
|
if (persisted) {
|
|
@@ -370,7 +383,7 @@ export const connectCommand: Command = {
|
|
|
370
383
|
// launches a browser of its own instead of reusing this session.
|
|
371
384
|
// launched:false marks this browser as someone else's — close must never
|
|
372
385
|
// kill it, and a dead endpoint must not be silently relaunched.
|
|
373
|
-
await browser.saveSessionRecord(port, { launched: false });
|
|
386
|
+
await browser.saveSessionRecord(port, { launched: false, name: session.name || undefined });
|
|
374
387
|
const url = await browser.getCurrentUrl(session.client, session.sessionId);
|
|
375
388
|
const title = await browser.getCurrentTitle(session.client, session.sessionId);
|
|
376
389
|
output.printSuccess(`Connected: ${title} (${url})`);
|
package/src/cli/commands.ts
CHANGED
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
} from './commands-trace.js';
|
|
91
91
|
import { output } from './output.js';
|
|
92
92
|
import { createPlatformCommand } from './platform.js';
|
|
93
|
-
import { applySessionPortFlag } from './session.js';
|
|
93
|
+
import { applySessionNameFlag, applySessionPortFlag } from './session.js';
|
|
94
94
|
import type { Command, CommandContext, CommandResult } from './types.js';
|
|
95
95
|
|
|
96
96
|
// Re-exported for direct unit testing — these were exported from this file
|
|
@@ -230,16 +230,33 @@ const PORT_OPTION = {
|
|
|
230
230
|
description: 'Act on the browse session on this CDP port (default: the newest live session)',
|
|
231
231
|
} as const;
|
|
232
232
|
|
|
233
|
+
const SESSION_OPTION = {
|
|
234
|
+
name: 'session',
|
|
235
|
+
short: 's',
|
|
236
|
+
type: 'string',
|
|
237
|
+
description: 'Act on the live browse session with this name (open/connect start it)',
|
|
238
|
+
} as const;
|
|
239
|
+
|
|
233
240
|
function withSessionSelector(cmd: Command): Command {
|
|
234
241
|
const action = cmd.action;
|
|
242
|
+
const options = cmd.options?.some((o) => o.name === 'port')
|
|
243
|
+
? (cmd.options ?? [])
|
|
244
|
+
: [...(cmd.options ?? []), { ...PORT_OPTION }];
|
|
235
245
|
return {
|
|
236
246
|
...cmd,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
// `snapshot`/`scroll` already use -s for --selector: long form only there.
|
|
248
|
+
options: options.some((o) => o.name === 'session')
|
|
249
|
+
? options
|
|
250
|
+
: [
|
|
251
|
+
...options,
|
|
252
|
+
options.some((o) => o.short === 's')
|
|
253
|
+
? { ...SESSION_OPTION, short: undefined }
|
|
254
|
+
: { ...SESSION_OPTION },
|
|
255
|
+
],
|
|
240
256
|
action: action
|
|
241
257
|
? async (ctx: CommandContext) => {
|
|
242
258
|
applySessionPortFlag(ctx.flags);
|
|
259
|
+
applySessionNameFlag(ctx.flags);
|
|
243
260
|
return action(ctx);
|
|
244
261
|
}
|
|
245
262
|
: undefined,
|
|
@@ -324,10 +341,7 @@ const browseCommand: Command = {
|
|
|
324
341
|
name: 'browse',
|
|
325
342
|
description: 'Native browser automation via Chrome DevTools Protocol',
|
|
326
343
|
subcommands,
|
|
327
|
-
options: [
|
|
328
|
-
{ ...PORT_OPTION },
|
|
329
|
-
{ name: 'session', short: 's', type: 'string', description: 'Named session to use' },
|
|
330
|
-
],
|
|
344
|
+
options: [{ ...PORT_OPTION }, { ...SESSION_OPTION }],
|
|
331
345
|
examples: [
|
|
332
346
|
{ command: 'monomind browse open https://example.com', description: 'Open a URL' },
|
|
333
347
|
{
|