@monoes/monobrowse 1.0.17 → 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__/browser-launch.test.js +132 -1
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- 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 +145 -42
- 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/browser/types.d.ts +2 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.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__/browser-launch.test.ts +142 -1
- 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 +149 -39
- package/src/browser/ref-cache.ts +144 -72
- package/src/browser/types.ts +2 -0
- 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/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #318: two uncoordinated `browse open` invocations with no `--port` raced on
|
|
3
|
+
* the shared default CDP port (9222) and the userDataDir derived from it —
|
|
4
|
+
* one failed outright ("Chrome exited before the CDP endpoint opened on port
|
|
5
|
+
* 9222"), or both attached to ONE Chrome and a single `browse close` killed
|
|
6
|
+
* the other caller's browser too.
|
|
7
|
+
*
|
|
8
|
+
* This is a real-process test on purpose: the bug lives in the interaction
|
|
9
|
+
* between two separate CLI processes and the session state they share on
|
|
10
|
+
* disk, which no in-process unit test can reproduce. It spawns the built CLI
|
|
11
|
+
* (dist/src/cli.js) against a file:// fixture page, so it is skipped when the
|
|
12
|
+
* package has not been built or no Chrome/Chromium is installed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
22
|
+
import { CHROME_EXECUTABLES } from '../browser/types.js';
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CLI = join(here, '..', '..', 'dist', 'src', 'cli.js');
|
|
26
|
+
|
|
27
|
+
function chromeAvailable(): boolean {
|
|
28
|
+
if (CHROME_EXECUTABLES.some((p) => existsSync(p))) return true;
|
|
29
|
+
try {
|
|
30
|
+
return (
|
|
31
|
+
execFileSync('which', ['google-chrome', 'chromium-browser', 'chromium'], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
}).trim().length > 0
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const runnable = existsSync(CLI) && chromeAvailable();
|
|
42
|
+
|
|
43
|
+
interface Run {
|
|
44
|
+
code: number | null;
|
|
45
|
+
stdout: string;
|
|
46
|
+
stderr: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runCli(args: string[], cwd: string, profileRoot: string): Promise<Run> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
52
|
+
cwd,
|
|
53
|
+
// Per-trial TMPDIR: every browser profile this invocation creates lands
|
|
54
|
+
// under it, so the sweep in afterEach can identify (and only kill) the
|
|
55
|
+
// Chrome processes this test started.
|
|
56
|
+
env: { ...process.env, TMPDIR: profileRoot, CI: '1' },
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
});
|
|
59
|
+
let stdout = '';
|
|
60
|
+
let stderr = '';
|
|
61
|
+
child.stdout.on('data', (d) => {
|
|
62
|
+
stdout += d;
|
|
63
|
+
});
|
|
64
|
+
child.stderr.on('data', (d) => {
|
|
65
|
+
stderr += d;
|
|
66
|
+
});
|
|
67
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The CDP port `open` reports for the session it started. */
|
|
72
|
+
function reportedPort(run: Run): number | null {
|
|
73
|
+
const m = /\[port (\d+)\]/.exec(run.stdout);
|
|
74
|
+
return m ? Number(m[1]) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function cdpAlive(port: number): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
80
|
+
signal: AbortSignal.timeout(1500),
|
|
81
|
+
});
|
|
82
|
+
return res.ok;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const dirs: string[] = [];
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
for (const dir of dirs.splice(0)) {
|
|
92
|
+
// Kill anything still running out of this trial's profile root before the
|
|
93
|
+
// directory goes away — a leaked Chrome would otherwise outlive the run.
|
|
94
|
+
await new Promise<void>((resolve) => {
|
|
95
|
+
execFile('pkill', ['-f', dir], () => resolve());
|
|
96
|
+
});
|
|
97
|
+
// A killed Chrome keeps writing out its profile for a moment; retry the
|
|
98
|
+
// removal instead of failing the test on ENOTEMPTY.
|
|
99
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe.skipIf(!runnable)('#318 concurrent browse sessions (real processes)', () => {
|
|
104
|
+
for (const trial of [1, 2, 3, 4, 5]) {
|
|
105
|
+
it(`trial ${trial}: two bare \`open\` calls each get their own browser, and each close kills only its own`, async () => {
|
|
106
|
+
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-'));
|
|
107
|
+
dirs.push(root);
|
|
108
|
+
const page = join(root, 'fixture.html');
|
|
109
|
+
await writeFile(page, '<!doctype html><title>Fixture</title><h1>hello</h1>');
|
|
110
|
+
const url = `file://${page}`;
|
|
111
|
+
|
|
112
|
+
// The reported race: two uncoordinated invocations, same cwd, no --port.
|
|
113
|
+
const [a, b] = await Promise.all([
|
|
114
|
+
runCli(['open', url], root, root),
|
|
115
|
+
runCli(['open', url], root, root),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
expect(a.code, `open A failed: ${a.stdout}${a.stderr}`).toBe(0);
|
|
119
|
+
expect(b.code, `open B failed: ${b.stdout}${b.stderr}`).toBe(0);
|
|
120
|
+
|
|
121
|
+
const portA = reportedPort(a);
|
|
122
|
+
const portB = reportedPort(b);
|
|
123
|
+
expect(portA, `open A did not report a session port: ${a.stdout}`).not.toBeNull();
|
|
124
|
+
expect(portB, `open B did not report a session port: ${b.stdout}`).not.toBeNull();
|
|
125
|
+
expect(portA).not.toBe(portB);
|
|
126
|
+
|
|
127
|
+
expect(await cdpAlive(portA!)).toBe(true);
|
|
128
|
+
expect(await cdpAlive(portB!)).toBe(true);
|
|
129
|
+
|
|
130
|
+
// Closing A must leave B's browser running.
|
|
131
|
+
const closeA = await runCli(['close', '--port', String(portA)], root, root);
|
|
132
|
+
expect(closeA.code, `close A failed: ${closeA.stdout}${closeA.stderr}`).toBe(0);
|
|
133
|
+
expect(await cdpAlive(portA!)).toBe(false);
|
|
134
|
+
expect(await cdpAlive(portB!)).toBe(true);
|
|
135
|
+
|
|
136
|
+
const closeB = await runCli(['close', '--port', String(portB)], root, root);
|
|
137
|
+
expect(closeB.code, `close B failed: ${closeB.stdout}${closeB.stderr}`).toBe(0);
|
|
138
|
+
expect(await cdpAlive(portB!)).toBe(false);
|
|
139
|
+
}, 90_000);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Upgrading monobrowse while a session was open used to strand its Chrome:
|
|
145
|
+
* the handle lived in `active-port.json`, which the per-port store replaced.
|
|
146
|
+
* The new CLI adopts that record instead — the same real-process shape as
|
|
147
|
+
* above, since the whole point is what a *later* process can still do.
|
|
148
|
+
*/
|
|
149
|
+
describe.skipIf(!runnable)('#318 adoption of a pre-#318 session (real processes)', () => {
|
|
150
|
+
/** Open a session, then rewrite its state as a pre-#318 install left it. */
|
|
151
|
+
async function openThenDowngradeState(root: string, url: string): Promise<number> {
|
|
152
|
+
const opened = await runCli(['open', url], root, root);
|
|
153
|
+
expect(opened.code, `open failed: ${opened.stdout}${opened.stderr}`).toBe(0);
|
|
154
|
+
const port = reportedPort(opened);
|
|
155
|
+
expect(port).not.toBeNull();
|
|
156
|
+
|
|
157
|
+
const stateDir = join(root, '.monomind', 'monobrowse');
|
|
158
|
+
const record = await readFile(join(stateDir, 'sessions', `${port}.json`), 'utf8');
|
|
159
|
+
await writeFile(join(stateDir, 'active-port.json'), record);
|
|
160
|
+
await rm(join(stateDir, 'sessions'), { recursive: true, force: true });
|
|
161
|
+
return port!;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it('a later command adopts the legacy session, and close then kills that browser', async () => {
|
|
165
|
+
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-legacy-'));
|
|
166
|
+
dirs.push(root);
|
|
167
|
+
const page = join(root, 'fixture.html');
|
|
168
|
+
await writeFile(page, '<!doctype html><title>Fixture</title><h1>hello</h1>');
|
|
169
|
+
const port = await openThenDowngradeState(root, `file://${page}`);
|
|
170
|
+
|
|
171
|
+
// A later, separate process with no --port finds it.
|
|
172
|
+
const title = await runCli(['get', 'title'], root, root);
|
|
173
|
+
expect(title.code, `get title failed: ${title.stdout}${title.stderr}`).toBe(0);
|
|
174
|
+
expect(title.stdout).toContain('Fixture');
|
|
175
|
+
|
|
176
|
+
// Adopted: it is an ordinary per-port record now, and the old file is gone.
|
|
177
|
+
const stateDir = join(root, '.monomind', 'monobrowse');
|
|
178
|
+
expect(existsSync(join(stateDir, 'sessions', `${port}.json`))).toBe(true);
|
|
179
|
+
expect(existsSync(join(stateDir, 'active-port.json'))).toBe(false);
|
|
180
|
+
|
|
181
|
+
// And `--port` works on it exactly like a native session.
|
|
182
|
+
const closed = await runCli(['close', '--port', String(port)], root, root);
|
|
183
|
+
expect(closed.code, `close failed: ${closed.stdout}${closed.stderr}`).toBe(0);
|
|
184
|
+
expect(await cdpAlive(port)).toBe(false);
|
|
185
|
+
}, 90_000);
|
|
186
|
+
|
|
187
|
+
it('a legacy record whose browser is gone is cleaned up, and the command still works', async () => {
|
|
188
|
+
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-legacy-'));
|
|
189
|
+
dirs.push(root);
|
|
190
|
+
const stateDir = join(root, '.monomind', 'monobrowse');
|
|
191
|
+
await mkdir(stateDir, { recursive: true });
|
|
192
|
+
// A port nothing is listening on — the state an upgrade finds after the
|
|
193
|
+
// old browser died (or was killed) with its record left behind.
|
|
194
|
+
await writeFile(
|
|
195
|
+
join(stateDir, 'active-port.json'),
|
|
196
|
+
JSON.stringify({ port: 23499, launched: true, pid: 999999, savedAt: Date.now() }),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const closed = await runCli(['close'], root, root);
|
|
200
|
+
expect(closed.code, `close failed: ${closed.stdout}${closed.stderr}`).toBe(0);
|
|
201
|
+
expect(closed.stdout).toContain('No active browser session');
|
|
202
|
+
expect(existsSync(join(stateDir, 'active-port.json'))).toBe(false);
|
|
203
|
+
}, 60_000);
|
|
204
|
+
|
|
205
|
+
it('adoption does not change bare `open`: it still starts a browser of its own', async () => {
|
|
206
|
+
const root = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'monobrowse-318-legacy-'));
|
|
207
|
+
dirs.push(root);
|
|
208
|
+
const page = join(root, 'fixture.html');
|
|
209
|
+
await writeFile(page, '<!doctype html><title>Fixture</title><h1>hello</h1>');
|
|
210
|
+
const url = `file://${page}`;
|
|
211
|
+
const legacyPort = await openThenDowngradeState(root, url);
|
|
212
|
+
|
|
213
|
+
const fresh = await runCli(['open', url], root, root);
|
|
214
|
+
expect(fresh.code, `open failed: ${fresh.stdout}${fresh.stderr}`).toBe(0);
|
|
215
|
+
const freshPort = reportedPort(fresh);
|
|
216
|
+
expect(freshPort).not.toBe(legacyPort);
|
|
217
|
+
// Neither joined nor disturbed: both browsers are up, and the legacy
|
|
218
|
+
// record is still waiting to be adopted by whoever needs it.
|
|
219
|
+
expect(await cdpAlive(legacyPort)).toBe(true);
|
|
220
|
+
expect(await cdpAlive(freshPort!)).toBe(true);
|
|
221
|
+
expect(existsSync(join(root, '.monomind', 'monobrowse', 'active-port.json'))).toBe(true);
|
|
222
|
+
|
|
223
|
+
// Closing the new session leaves the legacy browser alone; a second
|
|
224
|
+
// close then adopts and ends it.
|
|
225
|
+
const closeFresh = await runCli(['close', '--port', String(freshPort)], root, root);
|
|
226
|
+
expect(closeFresh.code).toBe(0);
|
|
227
|
+
expect(await cdpAlive(legacyPort)).toBe(true);
|
|
228
|
+
|
|
229
|
+
const closeLegacy = await runCli(['close'], root, root);
|
|
230
|
+
expect(closeLegacy.code, `close failed: ${closeLegacy.stdout}${closeLegacy.stderr}`).toBe(0);
|
|
231
|
+
expect(await cdpAlive(legacyPort)).toBe(false);
|
|
232
|
+
}, 90_000);
|
|
233
|
+
});
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* services; each test binds/tears down its own listeners.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
13
14
|
import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
14
17
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
15
|
-
import { launchBrowser } from '../browser/browser.js';
|
|
18
|
+
import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
|
|
16
19
|
|
|
17
20
|
const BASE = 23470;
|
|
18
21
|
|
|
@@ -111,3 +114,141 @@ describe('launchBrowser — port scan/attach decisions', () => {
|
|
|
111
114
|
);
|
|
112
115
|
}, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
|
|
113
116
|
});
|
|
117
|
+
|
|
118
|
+
// A stand-in for the Chrome binary: like real Chrome given
|
|
119
|
+
// --remote-debugging-port=0, it binds a kernel-assigned port and only then
|
|
120
|
+
// writes that port to <user-data-dir>/DevToolsActivePort.
|
|
121
|
+
const FAKE_CHROME = `#!/usr/bin/env node
|
|
122
|
+
const { createServer } = require('node:http');
|
|
123
|
+
const { writeFileSync } = require('node:fs');
|
|
124
|
+
const { join } = require('node:path');
|
|
125
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
128
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
129
|
+
});
|
|
130
|
+
// Start slower than launchBrowser's first poll, so a stale file is read first.
|
|
131
|
+
setTimeout(() => server.listen(0, '127.0.0.1', () => {
|
|
132
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
|
|
133
|
+
}), 600);
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
describe.skipIf(process.platform === 'win32')(
|
|
137
|
+
'launchBrowser — port 0 (Chrome picks the port)',
|
|
138
|
+
() => {
|
|
139
|
+
it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
|
|
140
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-port0-'));
|
|
141
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
142
|
+
writeFileSync(exe, FAKE_CHROME);
|
|
143
|
+
chmodSync(exe, 0o755);
|
|
144
|
+
const profile = join(dir, 'profile');
|
|
145
|
+
// A Chrome-looking endpoint that is NOT ours, named by a stale
|
|
146
|
+
// DevToolsActivePort left in the profile dir by an earlier run.
|
|
147
|
+
const foreign = BASE + 5;
|
|
148
|
+
const s = createHttpServer((req, res) => {
|
|
149
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/1.0' }) : '[]');
|
|
151
|
+
});
|
|
152
|
+
s.on('connection', (sock) => sockets.push(sock));
|
|
153
|
+
await new Promise<void>((resolve) => s.listen(foreign, '127.0.0.1', () => resolve()));
|
|
154
|
+
servers.push(s);
|
|
155
|
+
mkdirSync(profile);
|
|
156
|
+
writeFileSync(join(profile, 'DevToolsActivePort'), `${foreign}\n/devtools/browser/stale`);
|
|
157
|
+
|
|
158
|
+
let port: number | undefined;
|
|
159
|
+
try {
|
|
160
|
+
port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
|
|
161
|
+
expect(port).not.toBe(foreign);
|
|
162
|
+
expect(port).toBeGreaterThan(0);
|
|
163
|
+
expect(getLaunchedPid(port)).toBeTypeOf('number');
|
|
164
|
+
} finally {
|
|
165
|
+
const pid = port === undefined ? undefined : getLaunchedPid(port);
|
|
166
|
+
if (pid) process.kill(pid, 'SIGKILL');
|
|
167
|
+
rmSync(dir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses port 0 without a dedicated userDataDir', async () => {
|
|
172
|
+
await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// A stand-in for the Chrome binary that, unlike FAKE_CHROME above, actually
|
|
178
|
+
// binds the FIXED port it is given via --remote-debugging-port (real Chrome,
|
|
179
|
+
// launched with no --port flag, always resolves to the same literal default
|
|
180
|
+
// port too — see cli/commands.ts's `port` option default). If the bind
|
|
181
|
+
// fails (another process already has it), it exits the way real Chrome does
|
|
182
|
+
// when a concurrent launch wins the same "free-looking" port: before its
|
|
183
|
+
// CDP endpoint ever opens.
|
|
184
|
+
const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
|
|
185
|
+
const { createServer } = require('node:http');
|
|
186
|
+
const { writeFileSync, mkdirSync } = require('node:fs');
|
|
187
|
+
const { join } = require('node:path');
|
|
188
|
+
const port = Number(
|
|
189
|
+
process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
|
|
190
|
+
);
|
|
191
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
192
|
+
const server = createServer((req, res) => {
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
194
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
195
|
+
});
|
|
196
|
+
server.on('error', () => {
|
|
197
|
+
process.exit(21);
|
|
198
|
+
});
|
|
199
|
+
server.listen(port, '127.0.0.1', () => {
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
|
|
202
|
+
});
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
describe.skipIf(process.platform === 'win32')(
|
|
206
|
+
'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
|
|
207
|
+
() => {
|
|
208
|
+
it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-concurrent-'));
|
|
210
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
211
|
+
writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
|
|
212
|
+
chmodSync(exe, 0o755);
|
|
213
|
+
// Both calls target the SAME literal port on purpose: that is exactly
|
|
214
|
+
// what two real `monomind browse open` invocations with no --port flag
|
|
215
|
+
// do, since the CLI's `port` option always defaults to the same value
|
|
216
|
+
// whether or not the user passed it (cli/commands.ts:288).
|
|
217
|
+
const port = BASE + 10;
|
|
218
|
+
|
|
219
|
+
let ports: number[] = [];
|
|
220
|
+
try {
|
|
221
|
+
ports = await Promise.all([
|
|
222
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
223
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
224
|
+
]);
|
|
225
|
+
} finally {
|
|
226
|
+
for (const p of ports) {
|
|
227
|
+
const pid = getLaunchedPid(p);
|
|
228
|
+
if (pid) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 'SIGKILL');
|
|
231
|
+
} catch {
|
|
232
|
+
/* already gone */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
expect(ports).toHaveLength(2);
|
|
240
|
+
// The old probe-then-launch race let both calls observe the same
|
|
241
|
+
// candidate as free and spawn Chrome on it — one bind wins, the
|
|
242
|
+
// other's Chrome exits before its CDP endpoint opens and the whole
|
|
243
|
+
// launchBrowser() call rejects. Both resolving at all is the
|
|
244
|
+
// regression check; landing on different ports (rather than one
|
|
245
|
+
// silently adopting the other's browser) proves the scan actually
|
|
246
|
+
// moved on instead of colliding.
|
|
247
|
+
expect(ports[0]).not.toBe(ports[1]);
|
|
248
|
+
const dirs = ports.map((p) => getLaunchedUserDataDir(p));
|
|
249
|
+
expect(dirs[0]).not.toBe(dirs[1]);
|
|
250
|
+
expect(dirs[0]).toBeTruthy();
|
|
251
|
+
expect(dirs[1]).toBeTruthy();
|
|
252
|
+
}, 15000);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
@@ -72,10 +72,10 @@ afterEach(async () => {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
async function writePersistedPort(port: number, pid: number, savedAt: number): Promise<void> {
|
|
75
|
-
const dir = join(tempDir, '.monomind', 'monobrowse');
|
|
75
|
+
const dir = join(tempDir, '.monomind', 'monobrowse', 'sessions');
|
|
76
76
|
await mkdir(dir, { recursive: true });
|
|
77
77
|
await writeFile(
|
|
78
|
-
join(dir,
|
|
78
|
+
join(dir, `${port}.json`),
|
|
79
79
|
JSON.stringify({ port, pid, launched: true, savedAt }),
|
|
80
80
|
'utf-8',
|
|
81
81
|
);
|
|
@@ -20,7 +20,15 @@ const mocks = vi.hoisted(() => {
|
|
|
20
20
|
sessionId: 'S-PAGE',
|
|
21
21
|
target: { id: 'T-PAGE', url: 'https://x.test' },
|
|
22
22
|
})),
|
|
23
|
-
|
|
23
|
+
listSessionRecords: vi.fn(async () => []),
|
|
24
|
+
loadSessionRecord: vi.fn(async () => null),
|
|
25
|
+
loadLegacySessionRecord: vi.fn(async () => null),
|
|
26
|
+
removeLegacySessionRecord: vi.fn(async () => {}),
|
|
27
|
+
saveSessionRecord: vi.fn(async () => {}),
|
|
28
|
+
removeSessionRecord: vi.fn(async () => {}),
|
|
29
|
+
clearRefCache: vi.fn(async () => {}),
|
|
30
|
+
getLaunchedPid: vi.fn(() => undefined),
|
|
31
|
+
getLaunchedUserDataDir: vi.fn(() => undefined),
|
|
24
32
|
loadRefCache: vi.fn(async () => null),
|
|
25
33
|
switchToFrame: vi.fn(
|
|
26
34
|
async (): Promise<{ url: string | null; sessionId: string | null }> => ({
|
|
@@ -55,7 +63,9 @@ const ctx = (args: string[]): CommandContext => ({
|
|
|
55
63
|
|
|
56
64
|
beforeEach(() => {
|
|
57
65
|
vi.clearAllMocks();
|
|
58
|
-
mocks.browser.
|
|
66
|
+
mocks.browser.listSessionRecords.mockResolvedValue([]);
|
|
67
|
+
mocks.browser.loadSessionRecord.mockResolvedValue(null);
|
|
68
|
+
mocks.browser.loadLegacySessionRecord.mockResolvedValue(null);
|
|
59
69
|
mocks.browser.loadRefCache.mockResolvedValue(null);
|
|
60
70
|
mocks.browser.switchToFrame.mockResolvedValue({
|
|
61
71
|
url: 'https://f.test/frame',
|
|
@@ -108,20 +108,22 @@ function fakeChrome(port: number, targets: Partial<CdpTarget>[]): Promise<void>
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Sessions are recorded one file per port (#318).
|
|
112
|
+
const PORT_FILE = (port: number) =>
|
|
113
|
+
join(tempDir, '.monomind', 'monobrowse', 'sessions', `${port}.json`);
|
|
112
114
|
|
|
113
115
|
async function writePersistedPort(record: {
|
|
114
116
|
port: number;
|
|
115
117
|
pid?: number;
|
|
116
118
|
launched: boolean;
|
|
117
|
-
savedAt
|
|
119
|
+
savedAt?: number;
|
|
118
120
|
}): Promise<void> {
|
|
119
|
-
await mkdir(join(tempDir, '.monomind', 'monobrowse'), { recursive: true });
|
|
120
|
-
await writeFile(PORT_FILE(), JSON.stringify(record), 'utf-8');
|
|
121
|
+
await mkdir(join(tempDir, '.monomind', 'monobrowse', 'sessions'), { recursive: true });
|
|
122
|
+
await writeFile(PORT_FILE(record.port), JSON.stringify(record), 'utf-8');
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
async function portFileExists(): Promise<boolean> {
|
|
124
|
-
return readFile(PORT_FILE(), 'utf-8').then(
|
|
125
|
+
async function portFileExists(port: number): Promise<boolean> {
|
|
126
|
+
return readFile(PORT_FILE(port), 'utf-8').then(
|
|
125
127
|
() => true,
|
|
126
128
|
() => false,
|
|
127
129
|
);
|
|
@@ -161,7 +163,7 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
161
163
|
expect(lastSocket?.methods()).toContain('Browser.close');
|
|
162
164
|
// The port file is the session handle — a reaped session must not leave
|
|
163
165
|
// one behind for the next command to chase.
|
|
164
|
-
expect(await portFileExists()).toBe(false);
|
|
166
|
+
expect(await portFileExists(port)).toBe(false);
|
|
165
167
|
});
|
|
166
168
|
|
|
167
169
|
it('does NOT reap a launched instance that still has open page targets', async () => {
|
|
@@ -179,7 +181,7 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
179
181
|
|
|
180
182
|
expect(lastSocket).toBeNull();
|
|
181
183
|
expect(killSpy).not.toHaveBeenCalled();
|
|
182
|
-
expect(await portFileExists()).toBe(true);
|
|
184
|
+
expect(await portFileExists(port)).toBe(true);
|
|
183
185
|
});
|
|
184
186
|
|
|
185
187
|
it('does NOT reap a browser this tool never launched, however idle or old it is', async () => {
|
|
@@ -198,7 +200,7 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
198
200
|
|
|
199
201
|
expect(lastSocket).toBeNull();
|
|
200
202
|
expect(killSpy).not.toHaveBeenCalled();
|
|
201
|
-
expect(await portFileExists()).toBe(true);
|
|
203
|
+
expect(await portFileExists(port)).toBe(true);
|
|
202
204
|
});
|
|
203
205
|
|
|
204
206
|
it('does NOT reap while a client is still attached to a target', async () => {
|
|
@@ -219,7 +221,7 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
219
221
|
await expect(reapIdleLaunchedBrowser()).resolves.toBeNull();
|
|
220
222
|
|
|
221
223
|
expect(lastSocket).toBeNull();
|
|
222
|
-
expect(await portFileExists()).toBe(true);
|
|
224
|
+
expect(await portFileExists(port)).toBe(true);
|
|
223
225
|
});
|
|
224
226
|
|
|
225
227
|
it('does NOT reap an instance that is still inside the idle threshold', async () => {
|
|
@@ -233,14 +235,13 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
233
235
|
await expect(reapIdleLaunchedBrowser()).resolves.toBeNull();
|
|
234
236
|
|
|
235
237
|
expect(lastSocket).toBeNull();
|
|
236
|
-
expect(await portFileExists()).toBe(true);
|
|
238
|
+
expect(await portFileExists(port)).toBe(true);
|
|
237
239
|
});
|
|
238
240
|
|
|
239
241
|
it('does NOT reap when the persisted record has no savedAt (age unknowable)', async () => {
|
|
240
242
|
const port = BASE + 5;
|
|
241
243
|
await fakeChrome(port, []);
|
|
242
|
-
await
|
|
243
|
-
await writeFile(PORT_FILE(), JSON.stringify({ port, pid: 54326, launched: true }), 'utf-8');
|
|
244
|
+
await writePersistedPort({ port, pid: 54326, launched: true });
|
|
244
245
|
|
|
245
246
|
const { reapIdleLaunchedBrowser } = await loadBrowser();
|
|
246
247
|
await expect(reapIdleLaunchedBrowser()).resolves.toBeNull();
|
|
@@ -271,6 +272,6 @@ describe('#310 reapIdleLaunchedBrowser — give back a CDP port nothing is using
|
|
|
271
272
|
await expect(launchBrowser({ port })).resolves.toBe(port);
|
|
272
273
|
|
|
273
274
|
expect(lastSocket?.methods()).toContain('Browser.close');
|
|
274
|
-
expect(await portFileExists()).toBe(false);
|
|
275
|
+
expect(await portFileExists(port)).toBe(false);
|
|
275
276
|
});
|
|
276
277
|
});
|