@monoes/monobrowse 1.0.19 → 1.0.21
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.d.ts +15 -0
- package/dist/src/__tests__/browse-concurrency.test.d.ts.map +1 -0
- package/dist/src/__tests__/browse-concurrency.test.js +198 -0
- package/dist/src/__tests__/browse-concurrency.test.js.map +1 -0
- package/dist/src/__tests__/close-browser.test.js +2 -2
- package/dist/src/__tests__/close-browser.test.js.map +1 -1
- package/dist/src/__tests__/frame-command.test.js +12 -2
- package/dist/src/__tests__/frame-command.test.js.map +1 -1
- package/dist/src/__tests__/reap-idle-browser.test.js +13 -13
- package/dist/src/__tests__/reap-idle-browser.test.js.map +1 -1
- package/dist/src/__tests__/ref-cache.test.js +96 -58
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/__tests__/report-exit-code.test.js +1 -1
- package/dist/src/__tests__/session-discovery.test.d.ts +13 -0
- package/dist/src/__tests__/session-discovery.test.d.ts.map +1 -0
- package/dist/src/__tests__/session-discovery.test.js +218 -0
- package/dist/src/__tests__/session-discovery.test.js.map +1 -0
- package/dist/src/browser/browser.d.ts +2 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +17 -9
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +50 -22
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +122 -57
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/cli/commands-context.js +1 -1
- package/dist/src/cli/commands-context.js.map +1 -1
- package/dist/src/cli/commands-navigate.js +2 -2
- package/dist/src/cli/commands-navigate.js.map +1 -1
- package/dist/src/cli/commands-page.js +2 -2
- package/dist/src/cli/commands-page.js.map +1 -1
- package/dist/src/cli/commands-report.js +2 -2
- package/dist/src/cli/commands-report.js.map +1 -1
- package/dist/src/cli/commands-session.d.ts.map +1 -1
- package/dist/src/cli/commands-session.js +70 -56
- 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 +102 -66
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +66 -2
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +190 -33
- 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 +233 -0
- package/src/__tests__/close-browser.test.ts +2 -2
- package/src/__tests__/frame-command.test.ts +12 -2
- package/src/__tests__/reap-idle-browser.test.ts +15 -14
- package/src/__tests__/ref-cache.test.ts +112 -59
- package/src/__tests__/report-exit-code.test.ts +1 -1
- package/src/__tests__/session-discovery.test.ts +259 -0
- package/src/browser/browser.ts +23 -9
- package/src/browser/ref-cache.ts +144 -72
- package/src/cli/commands-context.ts +1 -1
- package/src/cli/commands-navigate.ts +2 -2
- package/src/cli/commands-page.ts +2 -2
- package/src/cli/commands-report.ts +2 -2
- package/src/cli/commands-session.ts +74 -55
- package/src/cli/commands.ts +106 -66
- package/src/cli/session.ts +208 -33
package/src/browser/ref-cache.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AX-tree ref cache
|
|
2
|
+
* Per-session CLI state: the browse session index, and the AX-tree ref cache
|
|
3
|
+
* each session owns.
|
|
4
|
+
*
|
|
5
|
+
* Both are keyed by CDP port and scoped to the working directory: one file
|
|
6
|
+
* per browse session under `sessions/`, one ref cache per session beside it.
|
|
7
|
+
* A single shared `active-port.json` / `ax-snapshot.json` pair used to stand
|
|
8
|
+
* for "the" session, which is why two concurrent `open` invocations in one
|
|
9
|
+
* directory overwrote each other's handle (#318).
|
|
3
10
|
*
|
|
4
11
|
* `snapshot` and `find` are separate CLI invocations — each starts a fresh
|
|
5
12
|
* node process, so the in-memory ElementRef Map built by captureSnapshot()
|
|
@@ -10,13 +17,19 @@
|
|
|
10
17
|
* The full AX-tree text/dump is intentionally NOT persisted — only the
|
|
11
18
|
* per-ref index, to keep the file small.
|
|
12
19
|
*/
|
|
13
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
21
|
import { join } from 'node:path';
|
|
15
22
|
import type { ElementRef } from './types.js';
|
|
16
23
|
|
|
17
24
|
const CACHE_DIR = join(process.cwd(), '.monomind', 'monobrowse');
|
|
18
|
-
const
|
|
19
|
-
const
|
|
25
|
+
const SESSIONS_DIR = join(CACHE_DIR, 'sessions');
|
|
26
|
+
const refCacheFile = (port: number) => join(CACHE_DIR, `ax-snapshot-${port}.json`);
|
|
27
|
+
const sessionFile = (port: number) => join(SESSIONS_DIR, `${port}.json`);
|
|
28
|
+
// The pre-#318 layout: one session per directory, plus its one ref cache.
|
|
29
|
+
// Still read (see loadLegacySessionRecord) so a session opened by an older
|
|
30
|
+
// CLI is adopted rather than orphaned when monobrowse is upgraded under it.
|
|
31
|
+
const LEGACY_SESSION_FILE = join(CACHE_DIR, 'active-port.json');
|
|
32
|
+
const LEGACY_REF_CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
|
|
20
33
|
|
|
21
34
|
/** Snapshot older than this is flagged as possibly stale (page may have changed). */
|
|
22
35
|
export const REF_CACHE_STALE_MS = 30_000;
|
|
@@ -36,8 +49,11 @@ export interface RefCacheEntry {
|
|
|
36
49
|
stale: boolean;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
/** Persist the current ref index for a target so a later CLI process can read
|
|
52
|
+
/** Persist the current ref index for a target so a later CLI process can read
|
|
53
|
+
* it back. Scoped to the session's port so two sessions open in the same
|
|
54
|
+
* directory keep their own refs instead of clobbering one file. */
|
|
40
55
|
export async function saveRefCache(
|
|
56
|
+
port: number,
|
|
41
57
|
targetId: string,
|
|
42
58
|
url: string,
|
|
43
59
|
refs: Map<string, ElementRef>,
|
|
@@ -50,7 +66,7 @@ export async function saveRefCache(
|
|
|
50
66
|
savedAt: Date.now(),
|
|
51
67
|
refs: [...refs.values()],
|
|
52
68
|
};
|
|
53
|
-
await writeFile(
|
|
69
|
+
await writeFile(refCacheFile(port), JSON.stringify(data));
|
|
54
70
|
} catch {
|
|
55
71
|
// Best-effort — persistence failure just means cross-process rehydration
|
|
56
72
|
// won't work; in-process (same run) behavior is unaffected.
|
|
@@ -62,9 +78,9 @@ export async function saveRefCache(
|
|
|
62
78
|
* different tab/target is ignored). Returns null if no usable cache exists —
|
|
63
79
|
* callers fall back to an empty in-memory Map, matching prior behavior.
|
|
64
80
|
*/
|
|
65
|
-
export async function loadRefCache(targetId: string): Promise<RefCacheEntry | null> {
|
|
81
|
+
export async function loadRefCache(port: number, targetId: string): Promise<RefCacheEntry | null> {
|
|
66
82
|
try {
|
|
67
|
-
const raw = await readFile(
|
|
83
|
+
const raw = await readFile(refCacheFile(port), 'utf8');
|
|
68
84
|
const data: RefCacheFile = JSON.parse(raw);
|
|
69
85
|
if (!data || !Array.isArray(data.refs) || data.targetId !== targetId) return null;
|
|
70
86
|
|
|
@@ -82,104 +98,160 @@ export async function loadRefCache(targetId: string): Promise<RefCacheEntry | nu
|
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/** Drop the persisted ref index — call whenever refs are invalidated (navigation, tab switch, close). */
|
|
85
|
-
export async function clearRefCache(): Promise<void> {
|
|
101
|
+
export async function clearRefCache(port: number): Promise<void> {
|
|
86
102
|
try {
|
|
87
|
-
await rm(
|
|
103
|
+
await rm(refCacheFile(port), { force: true });
|
|
88
104
|
} catch {
|
|
89
105
|
// Nothing to clear, or not writable — non-fatal.
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* falling back to the hardcoded default port and launching/attaching to a
|
|
98
|
-
* second, unrelated Chrome instance.
|
|
110
|
+
* A browse session: one Chrome instance, identified by the CDP port it
|
|
111
|
+
* listens on, recorded so a LATER CLI invocation (each command is its own
|
|
112
|
+
* node process — see module header) can find it again.
|
|
99
113
|
*/
|
|
100
|
-
export
|
|
114
|
+
export interface SessionRecord {
|
|
115
|
+
port: number;
|
|
116
|
+
/** true = monobrowse launched this Chrome (safe to Browser.close later);
|
|
117
|
+
* false = we attached to a browser someone else owns (never kill it). */
|
|
118
|
+
launched: boolean;
|
|
119
|
+
pid?: number;
|
|
120
|
+
userDataDir?: string;
|
|
121
|
+
savedAt?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record a browse session under its own port, so concurrent sessions in the
|
|
126
|
+
* same working directory coexist instead of overwriting one shared handle.
|
|
127
|
+
*
|
|
128
|
+
* `pid`/`userDataDir` are persisted so a later, fresh CLI process can still
|
|
129
|
+
* find and kill the Chrome this one launched. Without them, closeBrowser()'s
|
|
130
|
+
* PID-kill fallback can never fire outside the launching process, since the
|
|
131
|
+
* in-memory launchedPids Map (browser.ts) is empty there.
|
|
132
|
+
*/
|
|
133
|
+
export async function saveSessionRecord(
|
|
101
134
|
port: number,
|
|
102
|
-
opts?: { launched?: boolean; pid?: number; userDataDir?: string },
|
|
135
|
+
opts?: { launched?: boolean; pid?: number; userDataDir?: string; savedAt?: number },
|
|
103
136
|
): Promise<void> {
|
|
104
137
|
try {
|
|
105
|
-
await mkdir(
|
|
106
|
-
// `launched`
|
|
107
|
-
//
|
|
108
|
-
// else owns (must never be killed). Absent (old files) reads as launched
|
|
109
|
-
// — matches pre-flag behavior where every persisted port came from open.
|
|
138
|
+
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
139
|
+
// `launched` absent (older files) reads as launched — matches the
|
|
140
|
+
// pre-flag behavior where every persisted port came from `open`.
|
|
110
141
|
//
|
|
111
|
-
// `
|
|
112
|
-
//
|
|
113
|
-
// and
|
|
114
|
-
// PID-kill fallback can never fire outside the launching process, since
|
|
115
|
-
// the in-memory launchedPids Map (browser.ts) is empty there.
|
|
142
|
+
// `savedAt` is passed only when re-recording a session that already
|
|
143
|
+
// existed (adopting a legacy record), so its real age — which is what
|
|
144
|
+
// the idle reaper and the newest-first ordering read — is preserved.
|
|
116
145
|
await writeFile(
|
|
117
|
-
|
|
146
|
+
sessionFile(port),
|
|
118
147
|
JSON.stringify({
|
|
119
148
|
port,
|
|
120
149
|
launched: opts?.launched !== false,
|
|
121
150
|
pid: opts?.pid,
|
|
122
151
|
userDataDir: opts?.userDataDir,
|
|
123
|
-
savedAt: Date.now(),
|
|
152
|
+
savedAt: opts?.savedAt ?? Date.now(),
|
|
124
153
|
}),
|
|
125
154
|
);
|
|
126
155
|
} catch {
|
|
127
|
-
// Best-effort — persistence failure
|
|
128
|
-
//
|
|
156
|
+
// Best-effort — persistence failure costs the next process the ability to
|
|
157
|
+
// find this session, not this command's own correctness.
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
132
|
-
/** Forget
|
|
133
|
-
*
|
|
134
|
-
export async function
|
|
161
|
+
/** Forget one session (it was closed, or its browser is gone) so later
|
|
162
|
+
* invocations don't chase a dead endpoint. */
|
|
163
|
+
export async function removeSessionRecord(port: number): Promise<void> {
|
|
135
164
|
try {
|
|
136
|
-
await rm(
|
|
165
|
+
await rm(sessionFile(port), { force: true });
|
|
137
166
|
} catch {
|
|
138
|
-
// Best-effort — a stale
|
|
167
|
+
// Best-effort — a stale record only costs one failed probe later.
|
|
139
168
|
}
|
|
140
169
|
}
|
|
141
170
|
|
|
142
|
-
/**
|
|
143
|
-
export async function
|
|
144
|
-
|
|
171
|
+
/** The session recorded for `port`, or null if there is none / it's unreadable. */
|
|
172
|
+
export async function loadSessionRecord(port: number): Promise<SessionRecord | null> {
|
|
173
|
+
try {
|
|
174
|
+
return parseSessionRecord(await readFile(sessionFile(port), 'utf8'));
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
145
178
|
}
|
|
146
179
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
180
|
+
/**
|
|
181
|
+
* The session a pre-#318 CLI left behind in this directory, if any.
|
|
182
|
+
*
|
|
183
|
+
* That layout held exactly one session in `active-port.json`. Upgrading
|
|
184
|
+
* monobrowse while such a session was open would otherwise strand its
|
|
185
|
+
* Chrome: nothing reads that file any more, so the new CLI could neither
|
|
186
|
+
* find nor close it. It is read here as one more candidate session and then
|
|
187
|
+
* either adopted (rewritten as `sessions/<port>.json`) or dropped — see
|
|
188
|
+
* adoptLegacySession() in cli/session.ts. Nothing ever writes it again.
|
|
189
|
+
*/
|
|
190
|
+
export async function loadLegacySessionRecord(): Promise<SessionRecord | null> {
|
|
155
191
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
port?: unknown;
|
|
159
|
-
launched?: unknown;
|
|
160
|
-
pid?: unknown;
|
|
161
|
-
userDataDir?: unknown;
|
|
162
|
-
savedAt?: unknown;
|
|
163
|
-
};
|
|
164
|
-
if (
|
|
165
|
-
typeof data.port === 'number' &&
|
|
166
|
-
Number.isInteger(data.port) &&
|
|
167
|
-
data.port >= 1024 &&
|
|
168
|
-
data.port <= 65535
|
|
169
|
-
) {
|
|
170
|
-
return {
|
|
171
|
-
port: data.port,
|
|
172
|
-
launched: data.launched !== false,
|
|
173
|
-
pid:
|
|
174
|
-
typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
|
|
175
|
-
? data.pid
|
|
176
|
-
: undefined,
|
|
177
|
-
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
178
|
-
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
192
|
+
return parseSessionRecord(await readFile(LEGACY_SESSION_FILE, 'utf8'));
|
|
193
|
+
} catch {
|
|
181
194
|
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drop the pre-#318 files, once their session has been adopted or found
|
|
199
|
+
* dead. The single ref cache goes with them: it belonged to that one
|
|
200
|
+
* session, and nothing reads it any more either. */
|
|
201
|
+
export async function removeLegacySessionRecord(): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
await rm(LEGACY_SESSION_FILE, { force: true });
|
|
204
|
+
await rm(LEGACY_REF_CACHE_FILE, { force: true });
|
|
182
205
|
} catch {
|
|
206
|
+
// Best-effort — a leftover file is inert; it is only ever read again.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Every recorded session in this working directory, newest first. */
|
|
211
|
+
export async function listSessionRecords(): Promise<SessionRecord[]> {
|
|
212
|
+
let entries: string[];
|
|
213
|
+
try {
|
|
214
|
+
entries = await readdir(SESSIONS_DIR);
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const records: SessionRecord[] = [];
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!entry.endsWith('.json')) continue;
|
|
221
|
+
try {
|
|
222
|
+
const record = parseSessionRecord(await readFile(join(SESSIONS_DIR, entry), 'utf8'));
|
|
223
|
+
if (record) records.push(record);
|
|
224
|
+
} catch {
|
|
225
|
+
// Unreadable file — skip it rather than failing the whole listing.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return records.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseSessionRecord(raw: string): SessionRecord | null {
|
|
232
|
+
const data = JSON.parse(raw) as {
|
|
233
|
+
port?: unknown;
|
|
234
|
+
launched?: unknown;
|
|
235
|
+
pid?: unknown;
|
|
236
|
+
userDataDir?: unknown;
|
|
237
|
+
savedAt?: unknown;
|
|
238
|
+
};
|
|
239
|
+
if (
|
|
240
|
+
typeof data.port !== 'number' ||
|
|
241
|
+
!Number.isInteger(data.port) ||
|
|
242
|
+
data.port < 1024 ||
|
|
243
|
+
data.port > 65535
|
|
244
|
+
) {
|
|
183
245
|
return null;
|
|
184
246
|
}
|
|
247
|
+
return {
|
|
248
|
+
port: data.port,
|
|
249
|
+
launched: data.launched !== false,
|
|
250
|
+
pid:
|
|
251
|
+
typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0
|
|
252
|
+
? data.pid
|
|
253
|
+
: undefined,
|
|
254
|
+
userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
|
|
255
|
+
savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
|
|
256
|
+
};
|
|
185
257
|
}
|
|
@@ -94,7 +94,7 @@ export const stateCommand: Command = {
|
|
|
94
94
|
await browser.clearLocalStorage(c, sid);
|
|
95
95
|
await browser.clearSessionStorage(c, sid);
|
|
96
96
|
session.refs = new Map();
|
|
97
|
-
await browser.clearRefCache();
|
|
97
|
+
await browser.clearRefCache(session.port);
|
|
98
98
|
output.printSuccess('Browser state cleared (cookies, localStorage, sessionStorage, refs)');
|
|
99
99
|
return { success: true };
|
|
100
100
|
}
|
|
@@ -322,7 +322,7 @@ export const navigateCommand: Command = {
|
|
|
322
322
|
|
|
323
323
|
// Refs captured before this navigation may now point at different content.
|
|
324
324
|
session.refs = new Map();
|
|
325
|
-
await browser.clearRefCache();
|
|
325
|
+
await browser.clearRefCache(session.port);
|
|
326
326
|
|
|
327
327
|
output.printSuccess(`Navigated: ${direction}`);
|
|
328
328
|
return { success: true };
|
|
@@ -435,7 +435,7 @@ export const pushstateCommand: Command = {
|
|
|
435
435
|
// SPA navigation changes what's on the page without a full page load —
|
|
436
436
|
// refs captured before this pushState call may now resolve to different content.
|
|
437
437
|
session.refs = new Map();
|
|
438
|
-
await browser.clearRefCache();
|
|
438
|
+
await browser.clearRefCache(session.port);
|
|
439
439
|
output.printSuccess(`pushState: ${url}`);
|
|
440
440
|
return { success: true };
|
|
441
441
|
},
|
package/src/cli/commands-page.ts
CHANGED
|
@@ -71,7 +71,7 @@ export const snapshotCommand: Command = {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
session.refs = result.refs;
|
|
74
|
-
await browser.saveRefCache(session.targetId, result.url, session.refs);
|
|
74
|
+
await browser.saveRefCache(session.port, session.targetId, result.url, session.refs);
|
|
75
75
|
|
|
76
76
|
const applyOutputLimits = (text: string): string => {
|
|
77
77
|
const maxOutput = ctx.flags['max-output'] as number | undefined;
|
|
@@ -375,7 +375,7 @@ export const diffCommand: Command = {
|
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
session.refs = snap2.refs;
|
|
378
|
-
await browser.saveRefCache(session.targetId, snap2.url, session.refs);
|
|
378
|
+
await browser.saveRefCache(session.port, session.targetId, snap2.url, session.refs);
|
|
379
379
|
|
|
380
380
|
// Text diff
|
|
381
381
|
const lines1 = snap1.text.split('\n');
|
|
@@ -210,8 +210,8 @@ export const reportCommand: Command = {
|
|
|
210
210
|
session.sessionId = '';
|
|
211
211
|
session.targetId = '';
|
|
212
212
|
session.refs = new Map();
|
|
213
|
-
await browser.
|
|
214
|
-
await browser.clearRefCache();
|
|
213
|
+
await browser.removeSessionRecord(session.port);
|
|
214
|
+
await browser.clearRefCache(session.port);
|
|
215
215
|
};
|
|
216
216
|
|
|
217
217
|
let result:
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { output } from './output.js';
|
|
12
12
|
import {
|
|
13
|
+
adoptLegacySession,
|
|
13
14
|
detectAttentionNeeded,
|
|
14
15
|
ensureConnected,
|
|
15
|
-
ensureSignalCleanupHandlers,
|
|
16
16
|
getBrowser,
|
|
17
|
+
launchSessionBrowser,
|
|
18
|
+
pinnedPort,
|
|
19
|
+
resolveLiveSession,
|
|
17
20
|
session,
|
|
18
21
|
switchToHeaded,
|
|
19
22
|
} from './session.js';
|
|
@@ -23,7 +26,13 @@ export const openCommand: Command = {
|
|
|
23
26
|
name: 'open',
|
|
24
27
|
description: 'Open a URL in the browser. Usage: monomind browse open <url>',
|
|
25
28
|
options: [
|
|
26
|
-
{
|
|
29
|
+
{
|
|
30
|
+
name: 'port',
|
|
31
|
+
short: 'p',
|
|
32
|
+
type: 'number',
|
|
33
|
+
description:
|
|
34
|
+
'Attach to (or launch on) this CDP port. Default: a free port of this session’s own',
|
|
35
|
+
},
|
|
27
36
|
{
|
|
28
37
|
name: 'headed',
|
|
29
38
|
type: 'boolean',
|
|
@@ -37,7 +46,10 @@ export const openCommand: Command = {
|
|
|
37
46
|
const url = ctx.args[0] as string;
|
|
38
47
|
if (!url) throw new Error('URL required. Usage: monomind browse open <url>');
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
// No --port means "start a session of my own on a free port" (#318) —
|
|
50
|
+
// never "join whatever is on 9222", which is what made two uncoordinated
|
|
51
|
+
// `open` calls race for one browser and one profile directory.
|
|
52
|
+
const port = pinnedPort(ctx.flags);
|
|
41
53
|
const forceHeaded = ctx.flags.headed as boolean;
|
|
42
54
|
const browser = await getBrowser();
|
|
43
55
|
|
|
@@ -77,38 +89,9 @@ export const openCommand: Command = {
|
|
|
77
89
|
session.refs = new Map();
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
session
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
// persist the launched PID/userDataDir so a later process's closeBrowser
|
|
84
|
-
// can still kill this Chrome even though launchedPids (browser.ts) is
|
|
85
|
-
// per-process and empty there.
|
|
86
|
-
//
|
|
87
|
-
// launchBrowser() can either LAUNCH a fresh Chrome or ATTACH to one
|
|
88
|
-
// already listening on the requested port (see its own "attach if
|
|
89
|
-
// already Chrome" comment) — it returns only a port number, with no
|
|
90
|
-
// signal telling this caller which happened. getLaunchedPid(session.port) is
|
|
91
|
-
// undefined on the attach path (this process never spawned anything).
|
|
92
|
-
// Bug fixed here: unconditionally saving {pid: undefined, ...} on
|
|
93
|
-
// attach used to CLOBBER a real PID a previous `open` had already
|
|
94
|
-
// persisted for this exact port, destroying the only way a later
|
|
95
|
-
// process's closeBrowser() PID-kill fallback could ever find it.
|
|
96
|
-
const freshPid = browser.getLaunchedPid(session.port);
|
|
97
|
-
const freshUserDataDir = browser.getLaunchedUserDataDir(session.port);
|
|
98
|
-
if (freshPid !== undefined) {
|
|
99
|
-
await browser.saveActivePort(session.port, { pid: freshPid, userDataDir: freshUserDataDir });
|
|
100
|
-
} else {
|
|
101
|
-
// Attach path: preserve whatever PID/userDataDir was already on file
|
|
102
|
-
// for this port (most likely from the process that originally
|
|
103
|
-
// launched it) instead of overwriting with undefined.
|
|
104
|
-
const existing = await browser.loadActivePortInfo();
|
|
105
|
-
const preserved = existing && existing.port === session.port ? existing : undefined;
|
|
106
|
-
await browser.saveActivePort(session.port, {
|
|
107
|
-
pid: preserved?.pid,
|
|
108
|
-
userDataDir: preserved?.userDataDir,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
ensureSignalCleanupHandlers();
|
|
92
|
+
// Starts this session's own browser (and records it) unless --port named
|
|
93
|
+
// one to attach to — see the session rule in session.ts.
|
|
94
|
+
session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
|
|
112
95
|
const conn = await browser.connectToTarget(session.port);
|
|
113
96
|
session.client = conn.client;
|
|
114
97
|
session.sessionId = conn.sessionId;
|
|
@@ -117,7 +100,7 @@ export const openCommand: Command = {
|
|
|
117
100
|
// A snapshot taken before this navigation is no longer valid for the new
|
|
118
101
|
// page — drop the persisted ref cache so a stale process's weak
|
|
119
102
|
// time-based check can't resurrect it before the next explicit snapshot.
|
|
120
|
-
await browser.clearRefCache();
|
|
103
|
+
await browser.clearRefCache(session.port);
|
|
121
104
|
|
|
122
105
|
if (ctx.flags.state && ctx.flags.session) {
|
|
123
106
|
output.printWarning('Both --state and --session provided; --state takes precedence');
|
|
@@ -142,7 +125,7 @@ export const openCommand: Command = {
|
|
|
142
125
|
output.printWarning(
|
|
143
126
|
`${attentionType === 'captcha' ? 'CAPTCHA' : 'Login'} detected — switching to headed mode`,
|
|
144
127
|
);
|
|
145
|
-
await switchToHeaded(currentUrl, port);
|
|
128
|
+
await switchToHeaded(currentUrl, session.port);
|
|
146
129
|
await browser.openUrl(session.client!, session.sessionId, currentUrl);
|
|
147
130
|
output.printSuccess(
|
|
148
131
|
`Resumed headless after ${attentionType === 'captcha' ? 'CAPTCHA' : 'login'}`,
|
|
@@ -153,15 +136,27 @@ export const openCommand: Command = {
|
|
|
153
136
|
const finalUrl = await browser.getCurrentUrl(session.client!, session.sessionId);
|
|
154
137
|
const title = await browser.getCurrentTitle(session.client!, session.sessionId);
|
|
155
138
|
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
// The port is part of the result, not decoration: it is this session's
|
|
140
|
+
// handle, and the only way a concurrent caller can pin it from another
|
|
141
|
+
// process (`--port N`) instead of resolving "the newest live session".
|
|
142
|
+
output.printSuccess(`Opened: ${title} (${finalUrl}) [port ${session.port}]`);
|
|
143
|
+
return { success: true, data: { url: finalUrl, title, port: session.port } };
|
|
158
144
|
},
|
|
159
145
|
};
|
|
160
146
|
|
|
161
147
|
export const closeCommand: Command = {
|
|
162
148
|
name: 'close',
|
|
163
|
-
description:
|
|
164
|
-
|
|
149
|
+
description:
|
|
150
|
+
'Close a browser session. Usage: monomind browse close [--port <port>] (default: the newest live session here)',
|
|
151
|
+
options: [
|
|
152
|
+
{
|
|
153
|
+
name: 'port',
|
|
154
|
+
short: 'p',
|
|
155
|
+
type: 'number',
|
|
156
|
+
description: 'Close the session on this CDP port instead of the newest live one',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
action: async (ctx: CommandContext): Promise<CommandResult> => {
|
|
165
160
|
if (session.client) {
|
|
166
161
|
const browser = await getBrowser();
|
|
167
162
|
const sid = session.sessionId;
|
|
@@ -192,26 +187,48 @@ export const closeCommand: Command = {
|
|
|
192
187
|
browser.stopRequestCapture(sid);
|
|
193
188
|
browser.teardownDialogHandling(sid);
|
|
194
189
|
browser.teardownConsoleCapture(sid);
|
|
190
|
+
// Terminate the browser this process launched, not just our socket to
|
|
191
|
+
// it: with a session of its own on a free port (#318), a Chrome left
|
|
192
|
+
// behind here is one nothing else knows how to find. A browser we
|
|
193
|
+
// merely attached to (`connect`) is the user's — never killed.
|
|
194
|
+
const record = await browser.loadSessionRecord(session.port);
|
|
195
|
+
if (record?.launched !== false) {
|
|
196
|
+
try {
|
|
197
|
+
await browser.closeBrowser(client, session.port);
|
|
198
|
+
} catch {
|
|
199
|
+
/* best-effort — the record cleanup below still runs */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
195
202
|
client.close();
|
|
196
203
|
session.client = null;
|
|
197
204
|
session.sessionId = '';
|
|
198
205
|
session.parentSessionId = '';
|
|
199
206
|
session.targetId = '';
|
|
200
207
|
session.refs = new Map();
|
|
201
|
-
//
|
|
208
|
+
// This session is gone — forget its record and stale refs so later
|
|
202
209
|
// invocations don't chase a dead endpoint (or the wrong elements).
|
|
203
|
-
|
|
204
|
-
await browser.
|
|
205
|
-
|
|
210
|
+
// Other sessions' records are left alone.
|
|
211
|
+
await browser.removeSessionRecord(session.port);
|
|
212
|
+
await browser.clearRefCache(session.port);
|
|
213
|
+
output.printSuccess(`Browser session closed (port ${session.port})`);
|
|
206
214
|
} else {
|
|
207
215
|
// Each CLI invocation is a fresh process, so `close` almost always
|
|
208
|
-
// lands here. The
|
|
209
|
-
//
|
|
216
|
+
// lands here. The session record is the real handle: `--port` names
|
|
217
|
+
// one, otherwise it is the newest session whose browser still answers
|
|
218
|
+
// (strict:false — a dead one is cleaned up, not raised as an error).
|
|
219
|
+
// If monobrowse LAUNCHED that browser (open), gracefully Browser.close
|
|
210
220
|
// it — otherwise every open→close cycle leaks a headless Chrome. If we
|
|
211
221
|
// merely ATTACHED to it (connect, launched:false), never kill it: it's
|
|
212
|
-
// the user's own browser. Either way, forget
|
|
222
|
+
// the user's own browser. Either way, forget that one session.
|
|
213
223
|
const browser = await getBrowser();
|
|
214
|
-
const
|
|
224
|
+
const pinned = pinnedPort(ctx.flags);
|
|
225
|
+
const persisted = pinned
|
|
226
|
+
? ((await browser.loadSessionRecord(pinned)) ??
|
|
227
|
+
(await adoptLegacySession(browser, { port: pinned })) ?? {
|
|
228
|
+
port: pinned,
|
|
229
|
+
launched: true,
|
|
230
|
+
})
|
|
231
|
+
: await resolveLiveSession(browser, { strict: false });
|
|
215
232
|
if (persisted?.launched) {
|
|
216
233
|
try {
|
|
217
234
|
const conn = await browser.connectToTarget(persisted.port);
|
|
@@ -257,8 +274,10 @@ export const closeCommand: Command = {
|
|
|
257
274
|
: 'No active browser session',
|
|
258
275
|
);
|
|
259
276
|
}
|
|
260
|
-
|
|
261
|
-
|
|
277
|
+
if (persisted) {
|
|
278
|
+
await browser.removeSessionRecord(persisted.port);
|
|
279
|
+
await browser.clearRefCache(persisted.port);
|
|
280
|
+
}
|
|
262
281
|
}
|
|
263
282
|
return { success: true };
|
|
264
283
|
},
|
|
@@ -346,12 +365,12 @@ export const connectCommand: Command = {
|
|
|
346
365
|
session.targetId = conn.target.id;
|
|
347
366
|
session.port = port;
|
|
348
367
|
session.refs = new Map();
|
|
349
|
-
//
|
|
350
|
-
// (each command is a fresh process)
|
|
351
|
-
//
|
|
368
|
+
// Record the session like `open` does — without this, the NEXT CLI
|
|
369
|
+
// process (each command is a fresh process) has nothing to resolve and
|
|
370
|
+
// launches a browser of its own instead of reusing this session.
|
|
352
371
|
// launched:false marks this browser as someone else's — close must never
|
|
353
372
|
// kill it, and a dead endpoint must not be silently relaunched.
|
|
354
|
-
await browser.
|
|
373
|
+
await browser.saveSessionRecord(port, { launched: false });
|
|
355
374
|
const url = await browser.getCurrentUrl(session.client, session.sessionId);
|
|
356
375
|
const title = await browser.getCurrentTitle(session.client, session.sessionId);
|
|
357
376
|
output.printSuccess(`Connected: ${title} (${url})`);
|