@monoes/monobrowse 2.24.1 → 2.24.3
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/LICENSE +191 -0
- package/dist/src/__tests__/open-url-navigate-error.test.js +31 -0
- package/dist/src/__tests__/open-url-navigate-error.test.js.map +1 -1
- package/dist/src/__tests__/profile-dir.test.js +6 -2
- package/dist/src/__tests__/profile-dir.test.js.map +1 -1
- package/dist/src/browser/browser-lifecycle.d.ts +1 -1
- package/dist/src/browser/browser-lifecycle.d.ts.map +1 -1
- package/dist/src/browser/browser-lifecycle.js +24 -22
- package/dist/src/browser/browser-lifecycle.js.map +1 -1
- package/dist/src/browser/browser-page.d.ts.map +1 -1
- package/dist/src/browser/browser-page.js +5 -1
- package/dist/src/browser/browser-page.js.map +1 -1
- package/dist/src/browser/profile-dir.d.ts +1 -1
- package/dist/src/browser/profile-dir.d.ts.map +1 -1
- package/dist/src/browser/profile-dir.js +5 -4
- package/dist/src/browser/profile-dir.js.map +1 -1
- package/dist/src/cli/attention-wall.d.ts.map +1 -1
- package/dist/src/cli/attention-wall.js +46 -35
- package/dist/src/cli/attention-wall.js.map +1 -1
- package/dist/src/cli.js +0 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -9
- package/src/__tests__/open-url-navigate-error.test.ts +33 -0
- package/src/__tests__/profile-dir.test.ts +6 -2
- package/src/browser/browser-lifecycle.ts +26 -24
- package/src/browser/browser-page.ts +5 -1
- package/src/browser/profile-dir.ts +5 -4
- package/src/cli/attention-wall.ts +53 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monoes/monobrowse",
|
|
3
|
-
"version": "2.24.
|
|
3
|
+
"version": "2.24.3",
|
|
4
4
|
"description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,13 +37,6 @@
|
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": ">=22.12.0"
|
|
39
39
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html",
|
|
42
|
-
"test": "vitest run",
|
|
43
|
-
"test:coverage": "vitest run --coverage",
|
|
44
|
-
"prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
|
|
45
|
-
"prepublishOnly": "npm run build"
|
|
46
|
-
},
|
|
47
40
|
"keywords": [
|
|
48
41
|
"browser-automation",
|
|
49
42
|
"cdp",
|
|
@@ -70,5 +63,11 @@
|
|
|
70
63
|
"devDependencies": {
|
|
71
64
|
"typescript": "^7.0.2",
|
|
72
65
|
"vitest": "^4.1.4"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "tsc && node ../../../scripts/build-fs.mjs copy-into dist/src/browser/dashboard src/browser/dashboard/ui.html",
|
|
69
|
+
"test": "vitest run",
|
|
70
|
+
"test:coverage": "vitest run --coverage",
|
|
71
|
+
"prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo"
|
|
73
72
|
}
|
|
74
|
-
}
|
|
73
|
+
}
|
|
@@ -54,3 +54,36 @@ describe('openUrl navigation-failure detection', () => {
|
|
|
54
54
|
expect(calls).toHaveLength(0);
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
describe('openUrl on a page with a persistent connection (#670)', () => {
|
|
59
|
+
it('resolves once the page has loaded even though the network never goes idle', async () => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
try {
|
|
62
|
+
const handlers = new Map<string, (p: Record<string, unknown>, sid?: string) => void>();
|
|
63
|
+
const client = {
|
|
64
|
+
send: vi.fn(async (method: string) => {
|
|
65
|
+
if (method === 'Page.navigate') return { frameId: 'f1' };
|
|
66
|
+
if (method === 'Runtime.evaluate') return { result: { value: true } };
|
|
67
|
+
return {};
|
|
68
|
+
}),
|
|
69
|
+
on: vi.fn((event: string, fn: (p: Record<string, unknown>, sid?: string) => void) => {
|
|
70
|
+
handlers.set(event, fn);
|
|
71
|
+
// An SSE/long-poll request that never finishes.
|
|
72
|
+
if (event === 'Network.requestWillBeSent') {
|
|
73
|
+
queueMicrotask(() => fn({ requestId: 'sse' }, 'sess-1'));
|
|
74
|
+
}
|
|
75
|
+
return () => handlers.delete(event);
|
|
76
|
+
}),
|
|
77
|
+
} as unknown as CdpClient;
|
|
78
|
+
|
|
79
|
+
const done = openUrl(client, 'sess-1', 'http://127.0.0.1:1/login').then(
|
|
80
|
+
() => 'ok',
|
|
81
|
+
(e: Error) => e.message,
|
|
82
|
+
);
|
|
83
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
84
|
+
await expect(done).resolves.toBe('ok');
|
|
85
|
+
} finally {
|
|
86
|
+
vi.useRealTimers();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -68,13 +68,15 @@ async function profile(name: string, opts: { young?: boolean } = {}): Promise<st
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
describe('#395 profileDirOwnerPid', () => {
|
|
71
|
-
it('reads the pid from exactly our
|
|
71
|
+
it('reads the pid from exactly our name patterns', () => {
|
|
72
72
|
expect(profileDirOwnerPid('monomind-browser-9222-1234-0a1b2c3d')).toBe(1234);
|
|
73
73
|
expect(profileDirOwnerPid('monomind-browse-4321-0a1b2c3d')).toBe(4321);
|
|
74
|
+
expect(profileDirOwnerPid('monomind-browse-headed-4321-0a1b2c3d')).toBe(4321);
|
|
74
75
|
for (const name of [
|
|
75
76
|
'monomind-browse-4321-0A1B2C3D',
|
|
76
77
|
'monomind-browse-4321-0a1b2c3d0',
|
|
77
78
|
'monomind-browse-x-0a1b2c3d',
|
|
79
|
+
'monomind-browse-headed-x-0a1b2c3d',
|
|
78
80
|
'monomind-browser-4321-0a1b2c3d',
|
|
79
81
|
'xmonomind-browse-4321-0a1b2c3d',
|
|
80
82
|
'org.chromium.Chromium.abc123',
|
|
@@ -89,12 +91,14 @@ describe('#395 sweepStaleProfileDirs', () => {
|
|
|
89
91
|
const pid = await deadPid();
|
|
90
92
|
const a = await profile(`monomind-browser-9222-${pid}-aaaaaaaa`);
|
|
91
93
|
const b = await profile(`monomind-browse-${pid}-bbbbbbbb`);
|
|
94
|
+
const c = await profile(`monomind-browse-headed-${pid}-cccccccc`);
|
|
92
95
|
|
|
93
96
|
const removed = await sweepStaleProfileDirs({ root });
|
|
94
97
|
|
|
95
|
-
expect(removed.sort()).toEqual([a, b].sort());
|
|
98
|
+
expect(removed.sort()).toEqual([a, b, c].sort());
|
|
96
99
|
expect(existsSync(a)).toBe(false);
|
|
97
100
|
expect(existsSync(b)).toBe(false);
|
|
101
|
+
expect(existsSync(c)).toBe(false);
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
it('keeps young dirs, dirs whose pid is alive, non-matching names, symlinks and files', async () => {
|
|
@@ -48,32 +48,34 @@ const REAP_CONNECT_TIMEOUT_MS = 3000;
|
|
|
48
48
|
* the process is gone, on both paths (#395). In a fresh process the dir and
|
|
49
49
|
* its ownership come from the persisted session record.
|
|
50
50
|
*/
|
|
51
|
-
export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
|
|
51
|
+
export async function closeBrowser(client: CdpClient | null, port: number): Promise<void> {
|
|
52
52
|
let gracefullyClosed = false;
|
|
53
53
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
54
|
+
if (client) {
|
|
55
|
+
try {
|
|
56
|
+
await Promise.race([
|
|
57
|
+
client.send('Browser.close', {}),
|
|
58
|
+
// Deliberately NOT unref'd, for the same reason waitForProcessExit()'s
|
|
59
|
+
// poll below is not. This timer is the only thing that settles the race
|
|
60
|
+
// when Chrome's socket dies without ever acknowledging the close, and
|
|
61
|
+
// closeBrowser() is actively awaited by callers. An unref'd timer does
|
|
62
|
+
// not hold the event loop open, so Node would consider the loop drained
|
|
63
|
+
// and exit — status 0, before the caller's verdict was ever printed.
|
|
64
|
+
// Cleared in the finally so a close that IS acknowledged does not pin
|
|
65
|
+
// the loop for the rest of BROWSER_CLOSE_TIMEOUT_MS.
|
|
66
|
+
new Promise<never>((_, reject) => {
|
|
67
|
+
closeTimer = setTimeout(
|
|
68
|
+
() => reject(new Error('Browser.close timed out')),
|
|
69
|
+
BROWSER_CLOSE_TIMEOUT_MS,
|
|
70
|
+
);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
gracefullyClosed = true;
|
|
74
|
+
} catch {
|
|
75
|
+
// fall through to PID-kill fallback below
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(closeTimer);
|
|
78
|
+
}
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
let pid = launchedPids.get(port);
|
|
@@ -58,7 +58,11 @@ export async function openUrl(client: CdpClient, sessionId: string, url: string)
|
|
|
58
58
|
// connection previously reported success against that error page.
|
|
59
59
|
const nav = await client.send<{ errorText?: string }>('Page.navigate', { url }, sessionId);
|
|
60
60
|
if (nav.errorText) throw new Error(`Navigation to ${url} failed: ${nav.errorText}`);
|
|
61
|
-
await
|
|
61
|
+
await waitForLoad(client, sessionId, 'load', 30_000);
|
|
62
|
+
// Best-effort settle for SPAs that keep fetching after `load`. A page holding
|
|
63
|
+
// an SSE/long-poll connection (e.g. the monomind dashboard) never goes idle,
|
|
64
|
+
// so a timeout here is not a failure (#670).
|
|
65
|
+
await waitForNetworkIdle(client, sessionId, 500, 5_000).catch(() => undefined);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export async function waitForLoad(
|
|
@@ -25,8 +25,8 @@ import { basename, dirname, join } from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
/** `monomind-browser-<port>-<pid>-<uuid8>` (launchBrowser's default profile). */
|
|
27
27
|
const LAUNCH_PROFILE_RE = /^monomind-browser-\d+-(\d+)-[0-9a-f]{8}$/;
|
|
28
|
-
/** `monomind-browse-<pid>-<uuid8>` (a CLI browse session's profile). */
|
|
29
|
-
const SESSION_PROFILE_RE = /^monomind-browse
|
|
28
|
+
/** `monomind-browse-<pid>-<uuid8>` or `monomind-browse-headed-<pid>-<uuid8>` (a CLI browse session's profile). */
|
|
29
|
+
const SESSION_PROFILE_RE = /^monomind-browse(?:-headed)?-(\d+)-[0-9a-f]{8}$/;
|
|
30
30
|
|
|
31
31
|
/** A directory younger than this is never swept: its launch may still be
|
|
32
32
|
* starting (Chrome has not yet put its pid anywhere we can see). */
|
|
@@ -63,8 +63,9 @@ export function launchProfileDirPath(port: number, id: string, root = tmpdir()):
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Name for a new CLI session profile, directly under `root`. */
|
|
66
|
-
export function sessionProfileDirPath(id: string, root = tmpdir()): string {
|
|
67
|
-
|
|
66
|
+
export function sessionProfileDirPath(id: string, root = tmpdir(), headed = false): string {
|
|
67
|
+
const prefix = headed ? 'monomind-browse-headed' : 'monomind-browse';
|
|
68
|
+
return join(root, `${prefix}-${process.pid}-${id}`);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** The pid encoded in a profile directory name we create, or null when
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
+
import { sessionProfileDirPath } from '../browser/profile-dir.js';
|
|
12
13
|
import type { CdpClient } from '../index.js';
|
|
13
14
|
import { output } from './output.js';
|
|
14
15
|
import { getBrowser, session } from './session.js';
|
|
@@ -110,10 +111,8 @@ export async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
|
110
111
|
headedPort = await browser.launchBrowser({
|
|
111
112
|
port: 0,
|
|
112
113
|
headless: false,
|
|
113
|
-
userDataDir:
|
|
114
|
-
|
|
115
|
-
`monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
|
|
116
|
-
),
|
|
114
|
+
userDataDir: sessionProfileDirPath(randomUUID().slice(0, 8), tmpdir(), true),
|
|
115
|
+
ownsUserDataDir: true,
|
|
117
116
|
});
|
|
118
117
|
} catch (err) {
|
|
119
118
|
// Headed launch failed (no display, locked down env). Restore headless and rethrow.
|
|
@@ -127,51 +126,60 @@ export async function switchToHeaded(url: string, port: number): Promise<void> {
|
|
|
127
126
|
`Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
127
|
);
|
|
129
128
|
}
|
|
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
129
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
/* ignore */
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
await browser.openUrl(session.client, session.sessionId, url);
|
|
130
|
+
let authCookies: unknown[] = [];
|
|
131
|
+
let authLocalStorage: Record<string, string> = {};
|
|
132
|
+
try {
|
|
133
|
+
const conn = await browser.connectToTarget(headedPort);
|
|
134
|
+
session.client = conn.client;
|
|
135
|
+
session.sessionId = conn.sessionId;
|
|
136
|
+
session.targetId = conn.target.id;
|
|
137
|
+
session.refs = new Map();
|
|
148
138
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
139
|
+
if (savedCookies.length) {
|
|
140
|
+
try {
|
|
141
|
+
await browser.setCookies(
|
|
142
|
+
session.client,
|
|
143
|
+
session.sessionId,
|
|
144
|
+
savedCookies as Parameters<typeof browser.setCookies>[2],
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await browser.openUrl(session.client, session.sessionId, url);
|
|
153
151
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const authLocalStorage = await browser
|
|
159
|
-
.getLocalStorage(session.client, session.sessionId)
|
|
160
|
-
.catch(() => ({}) as Record<string, string>);
|
|
152
|
+
output.printInfo(
|
|
153
|
+
'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
|
|
154
|
+
);
|
|
155
|
+
await waitForEnter();
|
|
161
156
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
157
|
+
// Capture post-auth cookies
|
|
158
|
+
authCookies = await browser
|
|
159
|
+
.getCookies(session.client, session.sessionId)
|
|
160
|
+
.catch(() => [] as unknown[]);
|
|
161
|
+
authLocalStorage = await browser
|
|
162
|
+
.getLocalStorage(session.client, session.sessionId)
|
|
163
|
+
.catch(() => ({}) as Record<string, string>);
|
|
164
|
+
} finally {
|
|
165
|
+
// Close headed session — actually terminate the underlying Chrome process
|
|
166
|
+
// (Browser.close CDP command, PID-kill fallback), not just our CDP client
|
|
167
|
+
// connection, so the visible authenticated window doesn't linger forever.
|
|
168
|
+
if (session.sessionId) {
|
|
169
|
+
browser.teardownRouteInterception(session.sessionId);
|
|
170
|
+
browser.stopRequestCapture(session.sessionId);
|
|
171
|
+
browser.teardownDialogHandling(session.sessionId);
|
|
172
|
+
browser.teardownConsoleCapture(session.sessionId);
|
|
173
|
+
}
|
|
174
|
+
await browser.closeBrowser(session.client, headedPort);
|
|
175
|
+
if (session.client) {
|
|
176
|
+
session.client.close();
|
|
177
|
+
session.client = null;
|
|
178
|
+
}
|
|
179
|
+
session.sessionId = '';
|
|
180
|
+
session.parentSessionId = '';
|
|
181
|
+
session.targetId = '';
|
|
182
|
+
}
|
|
175
183
|
|
|
176
184
|
// Relaunch headless and restore session
|
|
177
185
|
session.port = await browser.launchBrowser({ port, headless: true });
|