@monoes/monobrowse 1.0.26 → 1.0.28
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__/disconnect-session.test.d.ts +13 -0
- package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
- package/dist/src/__tests__/disconnect-session.test.js +37 -0
- package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
- package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
- package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
- package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
- package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
- package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
- package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
- package/dist/src/__tests__/profile-dir.test.js +160 -0
- package/dist/src/__tests__/profile-dir.test.js.map +1 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
- package/dist/src/__tests__/setup/isolated-tmpdir.global.js.map +1 -0
- package/dist/src/browser/actions-eval.d.ts +8 -0
- package/dist/src/browser/actions-eval.d.ts.map +1 -0
- package/dist/src/browser/actions-eval.js +72 -0
- package/dist/src/browser/actions-eval.js.map +1 -0
- package/dist/src/browser/actions-form.d.ts +10 -0
- package/dist/src/browser/actions-form.d.ts.map +1 -0
- package/dist/src/browser/actions-form.js +133 -0
- package/dist/src/browser/actions-form.js.map +1 -0
- package/dist/src/browser/actions-keyboard.d.ts +7 -0
- package/dist/src/browser/actions-keyboard.d.ts.map +1 -0
- package/dist/src/browser/actions-keyboard.js +131 -0
- package/dist/src/browser/actions-keyboard.js.map +1 -0
- package/dist/src/browser/actions-mouse.d.ts +11 -0
- package/dist/src/browser/actions-mouse.d.ts.map +1 -0
- package/dist/src/browser/actions-mouse.js +105 -0
- package/dist/src/browser/actions-mouse.js.map +1 -0
- package/dist/src/browser/actions.d.ts +12 -28
- package/dist/src/browser/actions.d.ts.map +1 -1
- package/dist/src/browser/actions.js +12 -434
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/browser-discovery.d.ts +26 -0
- package/dist/src/browser/browser-discovery.d.ts.map +1 -0
- package/dist/src/browser/browser-discovery.js +113 -0
- package/dist/src/browser/browser-discovery.js.map +1 -0
- package/dist/src/browser/browser-launch.d.ts +3 -0
- package/dist/src/browser/browser-launch.d.ts.map +1 -0
- package/dist/src/browser/browser-launch.js +341 -0
- package/dist/src/browser/browser-launch.js.map +1 -0
- package/dist/src/browser/browser-lifecycle.d.ts +57 -0
- package/dist/src/browser/browser-lifecycle.d.ts.map +1 -0
- package/dist/src/browser/browser-lifecycle.js +245 -0
- package/dist/src/browser/browser-lifecycle.js.map +1 -0
- package/dist/src/browser/browser-page.d.ts +13 -0
- package/dist/src/browser/browser-page.d.ts.map +1 -0
- package/dist/src/browser/browser-page.js +179 -0
- package/dist/src/browser/browser-page.js.map +1 -0
- package/dist/src/browser/browser-state.d.ts +11 -0
- package/dist/src/browser/browser-state.d.ts.map +1 -0
- package/dist/src/browser/browser-state.js +27 -0
- package/dist/src/browser/browser-state.js.map +1 -0
- package/dist/src/browser/browser.d.ts +5 -68
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +9 -849
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/profile-dir.d.ts +88 -0
- package/dist/src/browser/profile-dir.d.ts.map +1 -0
- package/dist/src/browser/profile-dir.js +242 -0
- package/dist/src/browser/profile-dir.js.map +1 -0
- package/dist/src/browser/ref-cache.d.ts +4 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +2 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/types.d.ts +6 -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.d.ts +1 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +2 -0
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +9 -0
- package/dist/src/cli/session.d.ts.map +1 -1
- package/dist/src/cli/session.js +21 -4
- package/dist/src/cli/session.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/disconnect-session.test.ts +42 -0
- package/src/__tests__/profile-cleanup-close.test.ts +285 -0
- package/src/__tests__/profile-dir.test.ts +196 -0
- package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
- package/src/browser/actions-eval.ts +118 -0
- package/src/browser/actions-form.ts +218 -0
- package/src/browser/actions-keyboard.ts +171 -0
- package/src/browser/actions-mouse.ts +212 -0
- package/src/browser/actions.ts +37 -712
- package/src/browser/browser-discovery.ts +116 -0
- package/src/browser/browser-launch.ts +375 -0
- package/src/browser/browser-lifecycle.ts +248 -0
- package/src/browser/browser-page.ts +213 -0
- package/src/browser/browser-state.ts +30 -0
- package/src/browser/browser.ts +21 -917
- package/src/browser/profile-dir.ts +249 -0
- package/src/browser/ref-cache.ts +7 -0
- package/src/browser/types.ts +6 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/session.ts +22 -4
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disconnectSession() — drops this process's CDP connection without touching
|
|
3
|
+
* the browser (#408).
|
|
4
|
+
*
|
|
5
|
+
* `monomind browse` runs inside a CLI that lets the event loop drain instead
|
|
6
|
+
* of calling process.exit() (ADR-R001). A CDP websocket left open after the
|
|
7
|
+
* subcommand printed its output kept every `monomind browse …` alive until the
|
|
8
|
+
* 5 s force-exit watchdog. The embedder calls disconnectSession() once the
|
|
9
|
+
* subcommand is done; the browser must survive it, because the next command
|
|
10
|
+
* (a new process) reconnects to that same session.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { CdpClient } from '../browser/cdp.js';
|
|
15
|
+
import { disconnectSession } from '../cli/commands.js';
|
|
16
|
+
import { session } from '../cli/session.js';
|
|
17
|
+
|
|
18
|
+
describe('disconnectSession', () => {
|
|
19
|
+
it('closes the CDP client and clears it, leaving the session record alone', () => {
|
|
20
|
+
const close = vi.fn();
|
|
21
|
+
const send = vi.fn();
|
|
22
|
+
session.client = { close, send } as unknown as CdpClient;
|
|
23
|
+
session.sessionId = 'sid-1';
|
|
24
|
+
session.port = 9333;
|
|
25
|
+
|
|
26
|
+
disconnectSession();
|
|
27
|
+
|
|
28
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
29
|
+
// No CDP command either: Browser.close would end the session others reuse.
|
|
30
|
+
expect(send).not.toHaveBeenCalled();
|
|
31
|
+
expect(session.client).toBeNull();
|
|
32
|
+
expect(session.sessionId).toBe('');
|
|
33
|
+
// The port pin survives so a later in-process command resolves the same session.
|
|
34
|
+
expect(session.port).toBe(9333);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is a no-op when no client was ever connected', () => {
|
|
38
|
+
session.client = null;
|
|
39
|
+
expect(() => disconnectSession()).not.toThrow();
|
|
40
|
+
expect(session.client).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #395: closing (or killing) a browser monobrowse launched deletes the temp
|
|
3
|
+
* profile dir monobrowse created for it — and never a caller-supplied one.
|
|
4
|
+
*
|
|
5
|
+
* No real Chrome: launchBrowser spawns a fake-chrome script that behaves
|
|
6
|
+
* like Chrome where it matters here (creates and writes into its
|
|
7
|
+
* --user-data-dir, serves /json/version, announces its browser id on stderr,
|
|
8
|
+
* writes DevToolsActivePort for port 0). Browser.close goes over a fake `ws`
|
|
9
|
+
* socket that kills the fixture, as a real Chrome exits on it. A client that
|
|
10
|
+
* never connected drives the kill fallback (its Browser.close fails at once).
|
|
11
|
+
*
|
|
12
|
+
* TMPDIR is pointed at a root of each test's own, so both the launch-time
|
|
13
|
+
* sweep and the default profile dir live there — never the real /tmp.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
23
|
+
|
|
24
|
+
let onBrowserClose: (() => void) | null = null;
|
|
25
|
+
let lastSocket: FakeWs | null = null;
|
|
26
|
+
|
|
27
|
+
/** Acknowledges every command; `Browser.close` also runs onBrowserClose. */
|
|
28
|
+
class FakeWs {
|
|
29
|
+
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
|
30
|
+
constructor(public url: string) {
|
|
31
|
+
lastSocket = this;
|
|
32
|
+
}
|
|
33
|
+
on(event: string, fn: (...a: unknown[]) => void): void {
|
|
34
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
35
|
+
this.handlers.get(event)!.push(fn);
|
|
36
|
+
}
|
|
37
|
+
emit(event: string, ...args: unknown[]): void {
|
|
38
|
+
for (const fn of [...(this.handlers.get(event) ?? [])]) fn(...args);
|
|
39
|
+
}
|
|
40
|
+
send(data: string): void {
|
|
41
|
+
const { id, method } = JSON.parse(data) as { id: number; method: string };
|
|
42
|
+
if (method === 'Browser.close') onBrowserClose?.();
|
|
43
|
+
queueMicrotask(() => this.emit('message', Buffer.from(JSON.stringify({ id, result: {} }))));
|
|
44
|
+
}
|
|
45
|
+
close(): void {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
vi.mock('ws', () => ({ WebSocket: FakeWs }));
|
|
49
|
+
|
|
50
|
+
// Exits once the test process is gone, and within a minute regardless, so a
|
|
51
|
+
// fixture never outlives the run.
|
|
52
|
+
const FAKE_CHROME = `#!/usr/bin/env node
|
|
53
|
+
const parent = process.ppid;
|
|
54
|
+
setInterval(() => {
|
|
55
|
+
try { if (process.ppid !== parent) process.exit(0); process.kill(parent, 0); } catch { process.exit(0); }
|
|
56
|
+
}, 1000).unref();
|
|
57
|
+
setTimeout(() => process.exit(0), 60000).unref();
|
|
58
|
+
const { createServer } = require('node:http');
|
|
59
|
+
const { mkdirSync, writeFileSync } = require('node:fs');
|
|
60
|
+
const { join } = require('node:path');
|
|
61
|
+
const arg = (k) => process.argv.find((a) => a.startsWith(k + '=')).slice(k.length + 1);
|
|
62
|
+
const dir = arg('--user-data-dir');
|
|
63
|
+
const wanted = Number(arg('--remote-debugging-port'));
|
|
64
|
+
mkdirSync(join(dir, 'Default'), { recursive: true });
|
|
65
|
+
writeFileSync(join(dir, 'Default', 'Preferences'), '{}');
|
|
66
|
+
const server = createServer((req, res) => {
|
|
67
|
+
const port = server.address().port;
|
|
68
|
+
const ws = 'ws://127.0.0.1:' + port + '/devtools/browser/fake-' + process.pid;
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(req.url === '/json/version'
|
|
71
|
+
? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
|
|
72
|
+
: '[]');
|
|
73
|
+
});
|
|
74
|
+
server.listen(wanted, '127.0.0.1', () => {
|
|
75
|
+
const port = server.address().port;
|
|
76
|
+
if (wanted === 0) writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
|
|
77
|
+
process.stderr.write('DevTools listening on ws://127.0.0.1:' + port + '/devtools/browser/fake-' + process.pid + '\\n');
|
|
78
|
+
});
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
let root: string;
|
|
82
|
+
let fixtures: string;
|
|
83
|
+
let cwd: string;
|
|
84
|
+
let exe: string;
|
|
85
|
+
const children: ChildProcess[] = [];
|
|
86
|
+
const launchedPids: number[] = [];
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
root = await mkdtemp(join(tmpdir(), 'monobrowse-395-root-'));
|
|
90
|
+
fixtures = await mkdtemp(join(tmpdir(), 'monobrowse-395-fixture-'));
|
|
91
|
+
cwd = await mkdtemp(join(tmpdir(), 'monobrowse-395-cwd-'));
|
|
92
|
+
vi.stubEnv('TMPDIR', root);
|
|
93
|
+
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
|
94
|
+
exe = join(fixtures, 'fake-chrome.cjs');
|
|
95
|
+
writeFileSync(exe, FAKE_CHROME);
|
|
96
|
+
chmodSync(exe, 0o755);
|
|
97
|
+
onBrowserClose = null;
|
|
98
|
+
lastSocket = null;
|
|
99
|
+
vi.resetModules();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(async () => {
|
|
103
|
+
vi.unstubAllEnvs();
|
|
104
|
+
vi.restoreAllMocks();
|
|
105
|
+
for (const pid of launchedPids.splice(0)) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 'SIGKILL');
|
|
108
|
+
} catch {
|
|
109
|
+
/* already gone */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const child of children.splice(0)) child.kill('SIGKILL');
|
|
113
|
+
for (const dir of [root, fixtures, cwd]) await rm(dir, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function freePort(): Promise<number> {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const server = createServer();
|
|
119
|
+
server.once('error', reject);
|
|
120
|
+
server.listen(0, '127.0.0.1', () => {
|
|
121
|
+
const { port } = server.address() as { port: number };
|
|
122
|
+
server.close(() => resolve(port));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A client whose Browser.close is acknowledged, and makes `pid` exit the
|
|
128
|
+
* way Chrome does on it. */
|
|
129
|
+
async function gracefulClient(pid: number) {
|
|
130
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
131
|
+
const client = new CdpClient();
|
|
132
|
+
const p = client.connect('ws://127.0.0.1:1/devtools/browser/fake');
|
|
133
|
+
lastSocket!.emit('open');
|
|
134
|
+
await p;
|
|
135
|
+
onBrowserClose = () => process.kill(pid, 'SIGTERM');
|
|
136
|
+
return client;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A client that never connected: Browser.close fails at once, so
|
|
140
|
+
* closeBrowser takes its kill fallback. */
|
|
141
|
+
async function deadClient() {
|
|
142
|
+
const { CdpClient } = await import('../browser/cdp.js');
|
|
143
|
+
return new CdpClient();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function launch(config: Record<string, unknown>) {
|
|
147
|
+
const browser = await import('../browser/browser.js');
|
|
148
|
+
const port = await browser.launchBrowser({ executablePath: exe, ...config });
|
|
149
|
+
const pid = browser.getLaunchedPid(port)!;
|
|
150
|
+
launchedPids.push(pid);
|
|
151
|
+
return { browser, port, pid, dir: browser.getLaunchedUserDataDir(port)! };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe.skipIf(process.platform === 'win32')('#395 profile dir cleanup on close', () => {
|
|
155
|
+
it('launch then graceful close removes the default profile dir it created', async () => {
|
|
156
|
+
const { browser, port, pid, dir } = await launch({ port: await freePort() });
|
|
157
|
+
expect(dir.startsWith(root)).toBe(true);
|
|
158
|
+
expect(existsSync(join(dir, 'Default', 'Preferences'))).toBe(true);
|
|
159
|
+
expect(browser.ownsLaunchedUserDataDir(port)).toBe(true);
|
|
160
|
+
|
|
161
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
162
|
+
|
|
163
|
+
expect(existsSync(dir)).toBe(false);
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
it('removes a CLI session profile (port 0, ownsUserDataDir) on close', async () => {
|
|
167
|
+
const browser = await import('../browser/browser.js');
|
|
168
|
+
const { sessionProfileDirPath } = await import('../browser/profile-dir.js');
|
|
169
|
+
const own = sessionProfileDirPath('abcdef12');
|
|
170
|
+
expect(own.startsWith(root)).toBe(true);
|
|
171
|
+
const { port, pid, dir } = await launch({ port: 0, userDataDir: own, ownsUserDataDir: true });
|
|
172
|
+
expect(dir).toBe(own);
|
|
173
|
+
|
|
174
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
175
|
+
|
|
176
|
+
expect(existsSync(own)).toBe(false);
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
it('never deletes a caller-supplied userDataDir, even one named like ours', async () => {
|
|
180
|
+
const mine = join(root, 'my-profile');
|
|
181
|
+
const lookalike = join(root, `monomind-browse-${process.pid}-0badc0de`);
|
|
182
|
+
for (const userDataDir of [mine, lookalike]) {
|
|
183
|
+
mkdirSync(userDataDir);
|
|
184
|
+
const { browser, port, pid } = await launch({ port: 0, userDataDir });
|
|
185
|
+
expect(browser.ownsLaunchedUserDataDir(port)).toBe(false);
|
|
186
|
+
await browser.closeBrowser(await gracefulClient(pid), port);
|
|
187
|
+
expect(existsSync(join(userDataDir, 'Default', 'Preferences')), userDataDir).toBe(true);
|
|
188
|
+
}
|
|
189
|
+
}, 30_000);
|
|
190
|
+
|
|
191
|
+
it('the kill fallback removes the created dir once the process is gone', async () => {
|
|
192
|
+
const { browser, port, pid, dir } = await launch({ port: await freePort() });
|
|
193
|
+
expect(existsSync(dir)).toBe(true);
|
|
194
|
+
|
|
195
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
196
|
+
|
|
197
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
198
|
+
expect(existsSync(dir)).toBe(false);
|
|
199
|
+
}, 20_000);
|
|
200
|
+
|
|
201
|
+
it('a failed launch removes the profile dir it created', async () => {
|
|
202
|
+
const dying = join(fixtures, 'dying-chrome.cjs');
|
|
203
|
+
writeFileSync(
|
|
204
|
+
dying,
|
|
205
|
+
`#!/usr/bin/env node
|
|
206
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice(16);
|
|
207
|
+
require('node:fs').mkdirSync(dir, { recursive: true });
|
|
208
|
+
process.exit(1);
|
|
209
|
+
`,
|
|
210
|
+
);
|
|
211
|
+
chmodSync(dying, 0o755);
|
|
212
|
+
const browser = await import('../browser/browser.js');
|
|
213
|
+
const port = await freePort();
|
|
214
|
+
await expect(
|
|
215
|
+
browser.launchBrowser({ port, strictPort: true, executablePath: dying }),
|
|
216
|
+
).rejects.toThrow(/exited before the CDP endpoint opened/);
|
|
217
|
+
|
|
218
|
+
expect(readdirSync(root)).toEqual([]);
|
|
219
|
+
}, 20_000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe.skipIf(process.platform === 'win32')(
|
|
223
|
+
'#395 fresh-process close via the persisted session',
|
|
224
|
+
() => {
|
|
225
|
+
/** A live stand-in for a Chrome some earlier CLI process launched, its
|
|
226
|
+
* profile dir, and the session record that process left behind. */
|
|
227
|
+
async function persistedSession(ownsUserDataDir: boolean | undefined) {
|
|
228
|
+
const dir = join(
|
|
229
|
+
root,
|
|
230
|
+
`monomind-browse-${process.pid}-5e55107${ownsUserDataDir ? 'a' : 'b'}`,
|
|
231
|
+
);
|
|
232
|
+
await mkdir(join(dir, 'Default'), { recursive: true });
|
|
233
|
+
await writeFile(join(dir, 'Default', 'Preferences'), '{}');
|
|
234
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
|
235
|
+
stdio: 'ignore',
|
|
236
|
+
});
|
|
237
|
+
children.push(child);
|
|
238
|
+
await new Promise((resolve) => child.once('spawn', resolve));
|
|
239
|
+
const port = await freePort();
|
|
240
|
+
const sessions = join(cwd, '.monomind', 'monobrowse', 'sessions');
|
|
241
|
+
await mkdir(sessions, { recursive: true });
|
|
242
|
+
await writeFile(
|
|
243
|
+
join(sessions, `${port}.json`),
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
port,
|
|
246
|
+
launched: true,
|
|
247
|
+
pid: child.pid,
|
|
248
|
+
userDataDir: dir,
|
|
249
|
+
ownsUserDataDir,
|
|
250
|
+
savedAt: Date.now(),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
return { dir, pid: child.pid!, port };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('removes the dir when the record says monobrowse owns it', async () => {
|
|
257
|
+
const { dir, port } = await persistedSession(true);
|
|
258
|
+
const browser = await import('../browser/browser.js');
|
|
259
|
+
const { loadSessionRecord } = await import('../browser/ref-cache.js');
|
|
260
|
+
expect((await loadSessionRecord(port))?.ownsUserDataDir).toBe(true);
|
|
261
|
+
|
|
262
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
263
|
+
|
|
264
|
+
expect(existsSync(dir)).toBe(false);
|
|
265
|
+
}, 20_000);
|
|
266
|
+
|
|
267
|
+
it('keeps the dir (still killing the browser) when the record has no ownership flag', async () => {
|
|
268
|
+
const { dir, pid, port } = await persistedSession(undefined);
|
|
269
|
+
const browser = await import('../browser/browser.js');
|
|
270
|
+
|
|
271
|
+
await browser.closeBrowser(await deadClient(), port);
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < 40; i++) {
|
|
274
|
+
try {
|
|
275
|
+
process.kill(pid, 0);
|
|
276
|
+
} catch {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
280
|
+
}
|
|
281
|
+
expect(() => process.kill(pid, 0)).toThrow();
|
|
282
|
+
expect(existsSync(join(dir, 'Default', 'Preferences'))).toBe(true);
|
|
283
|
+
}, 20_000);
|
|
284
|
+
},
|
|
285
|
+
);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #395: the crash sweep and ownership checks for the temp Chrome profiles
|
|
3
|
+
* monobrowse creates (profile-dir.ts).
|
|
4
|
+
*
|
|
5
|
+
* Every test sweeps a root of its own made with mkdtemp — never the real
|
|
6
|
+
* /tmp, where other sessions' live Chrome profiles live. (The whole package
|
|
7
|
+
* run also gets a throwaway TMPDIR, see setup/isolated-tmpdir.global.ts.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { lutimes, mkdir, mkdtemp, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
isOwnedProfileDir,
|
|
18
|
+
isProfileDirInUse,
|
|
19
|
+
profileDirOwnerPid,
|
|
20
|
+
removeOwnedProfileDir,
|
|
21
|
+
sweepStaleProfileDirs,
|
|
22
|
+
} from '../browser/profile-dir.js';
|
|
23
|
+
|
|
24
|
+
let root: string;
|
|
25
|
+
let outside: string;
|
|
26
|
+
const children: ChildProcess[] = [];
|
|
27
|
+
const HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
root = await mkdtemp(join(tmpdir(), 'monobrowse-sweep-root-'));
|
|
31
|
+
outside = await mkdtemp(join(tmpdir(), 'monobrowse-sweep-outside-'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
for (const child of children.splice(0)) child.kill('SIGKILL');
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
await rm(outside, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A pid that belonged to a process which has since exited. */
|
|
41
|
+
async function deadPid(): Promise<number> {
|
|
42
|
+
const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' });
|
|
43
|
+
await new Promise((resolve) => child.once('exit', resolve));
|
|
44
|
+
return child.pid!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A live process whose command line passes `--user-data-dir=<dir>`, the way
|
|
48
|
+
* Chrome's own does. */
|
|
49
|
+
async function holder(dir: string): Promise<ChildProcess> {
|
|
50
|
+
const child = spawn(
|
|
51
|
+
process.execPath,
|
|
52
|
+
['-e', 'setInterval(() => {}, 1000)', '--', `--user-data-dir=${dir}`],
|
|
53
|
+
{ stdio: 'ignore' },
|
|
54
|
+
);
|
|
55
|
+
children.push(child);
|
|
56
|
+
await new Promise((resolve) => child.once('spawn', resolve));
|
|
57
|
+
return child;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A profile-looking directory with some content, backdated past the grace. */
|
|
61
|
+
async function profile(name: string, opts: { young?: boolean } = {}): Promise<string> {
|
|
62
|
+
const dir = join(root, name);
|
|
63
|
+
await mkdir(join(dir, 'Default'), { recursive: true });
|
|
64
|
+
await writeFile(join(dir, 'Default', 'Preferences'), '{}');
|
|
65
|
+
if (!opts.young) await utimes(dir, HOUR_AGO, HOUR_AGO);
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('#395 profileDirOwnerPid', () => {
|
|
70
|
+
it('reads the pid from exactly our two name patterns', () => {
|
|
71
|
+
expect(profileDirOwnerPid('monomind-browser-9222-1234-0a1b2c3d')).toBe(1234);
|
|
72
|
+
expect(profileDirOwnerPid('monomind-browse-4321-0a1b2c3d')).toBe(4321);
|
|
73
|
+
for (const name of [
|
|
74
|
+
'monomind-browse-4321-0A1B2C3D',
|
|
75
|
+
'monomind-browse-4321-0a1b2c3d0',
|
|
76
|
+
'monomind-browse-x-0a1b2c3d',
|
|
77
|
+
'monomind-browser-4321-0a1b2c3d',
|
|
78
|
+
'xmonomind-browse-4321-0a1b2c3d',
|
|
79
|
+
'org.chromium.Chromium.abc123',
|
|
80
|
+
]) {
|
|
81
|
+
expect(profileDirOwnerPid(name), name).toBeNull();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('#395 sweepStaleProfileDirs', () => {
|
|
87
|
+
it('deletes stale dirs whose owner is dead and nothing uses', async () => {
|
|
88
|
+
const pid = await deadPid();
|
|
89
|
+
const a = await profile(`monomind-browser-9222-${pid}-aaaaaaaa`);
|
|
90
|
+
const b = await profile(`monomind-browse-${pid}-bbbbbbbb`);
|
|
91
|
+
|
|
92
|
+
const removed = await sweepStaleProfileDirs({ root });
|
|
93
|
+
|
|
94
|
+
expect(removed.sort()).toEqual([a, b].sort());
|
|
95
|
+
expect(existsSync(a)).toBe(false);
|
|
96
|
+
expect(existsSync(b)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps young dirs, dirs whose pid is alive, non-matching names, symlinks and files', async () => {
|
|
100
|
+
const pid = await deadPid();
|
|
101
|
+
const young = await profile(`monomind-browse-${pid}-cccccccc`, { young: true });
|
|
102
|
+
const alive = await profile(`monomind-browse-${process.pid}-dddddddd`);
|
|
103
|
+
const foreign = [
|
|
104
|
+
await profile('org.chromium.Chromium.abc123'),
|
|
105
|
+
await profile(`monomind-browse-${pid}-ABCDEF12`),
|
|
106
|
+
await profile(`monomind-browse-${pid}-abcdef12-extra`),
|
|
107
|
+
await profile(`other-monomind-browse-${pid}-abcdef12`),
|
|
108
|
+
];
|
|
109
|
+
// A symlink with our name, pointing out of the root at a real profile.
|
|
110
|
+
const target = join(outside, 'victim');
|
|
111
|
+
await mkdir(target);
|
|
112
|
+
await writeFile(join(target, 'keep.txt'), 'keep');
|
|
113
|
+
const link = join(root, `monomind-browse-${pid}-11111111`);
|
|
114
|
+
await symlink(target, link);
|
|
115
|
+
await lutimes(link, HOUR_AGO, HOUR_AGO);
|
|
116
|
+
// A plain file with our name.
|
|
117
|
+
const file = join(root, `monomind-browse-${pid}-22222222`);
|
|
118
|
+
await writeFile(file, 'not a dir');
|
|
119
|
+
await utimes(file, HOUR_AGO, HOUR_AGO);
|
|
120
|
+
|
|
121
|
+
expect(await sweepStaleProfileDirs({ root })).toEqual([]);
|
|
122
|
+
|
|
123
|
+
for (const kept of [young, alive, ...foreign, link, file]) expect(existsSync(kept)).toBe(true);
|
|
124
|
+
expect(existsSync(join(target, 'keep.txt'))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.skipIf(process.platform !== 'linux')(
|
|
128
|
+
'keeps a dir a live process uses as --user-data-dir, though its named pid is dead',
|
|
129
|
+
async () => {
|
|
130
|
+
// A CLI `open` exits right after launching, so the pid in the name is
|
|
131
|
+
// dead while its Chrome runs on: only the live user of the dir says so.
|
|
132
|
+
const pid = await deadPid();
|
|
133
|
+
const inUse = await profile(`monomind-browse-${pid}-eeeeeeee`);
|
|
134
|
+
await holder(inUse);
|
|
135
|
+
|
|
136
|
+
expect(await sweepStaleProfileDirs({ root })).toEqual([]);
|
|
137
|
+
expect(existsSync(inUse)).toBe(true);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
it('without /proc, keeps a dir whose SingletonLock names a live pid', async () => {
|
|
142
|
+
const pid = await deadPid();
|
|
143
|
+
const locked = await profile(`monomind-browse-${pid}-ffffffff`);
|
|
144
|
+
await symlink(`somehost-${process.pid}`, join(locked, 'SingletonLock'));
|
|
145
|
+
await utimes(locked, HOUR_AGO, HOUR_AGO);
|
|
146
|
+
const staleLock = await profile(`monomind-browse-${pid}-99999999`);
|
|
147
|
+
await symlink(`somehost-${pid}`, join(staleLock, 'SingletonLock'));
|
|
148
|
+
await utimes(staleLock, HOUR_AGO, HOUR_AGO);
|
|
149
|
+
|
|
150
|
+
expect(await sweepStaleProfileDirs({ root, procDir: null })).toEqual([staleLock]);
|
|
151
|
+
expect(existsSync(locked)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('never throws, and sweeps nothing, when the root is missing', async () => {
|
|
155
|
+
await expect(sweepStaleProfileDirs({ root: join(root, 'missing') })).resolves.toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('#395 isOwnedProfileDir / removeOwnedProfileDir', () => {
|
|
160
|
+
it('refuses a caller-supplied dir, a dir outside the root, and a symlink', async () => {
|
|
161
|
+
const caller = join(root, 'my-profile');
|
|
162
|
+
await mkdir(caller);
|
|
163
|
+
const nested = join(root, 'sub', `monomind-browse-${process.pid}-aaaaaaaa`);
|
|
164
|
+
await mkdir(nested, { recursive: true });
|
|
165
|
+
const target = join(outside, 'victim');
|
|
166
|
+
await mkdir(target);
|
|
167
|
+
const link = join(root, `monomind-browse-${process.pid}-bbbbbbbb`);
|
|
168
|
+
await symlink(target, link);
|
|
169
|
+
|
|
170
|
+
for (const dir of [caller, nested, link]) {
|
|
171
|
+
expect(await isOwnedProfileDir(dir, root), dir).toBe(false);
|
|
172
|
+
expect(await removeOwnedProfileDir(dir, { root }), dir).toBe(false);
|
|
173
|
+
expect(existsSync(dir), dir).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
expect(existsSync(target)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('removes an owned dir once its browser pid has exited', async () => {
|
|
179
|
+
const dir = await profile(`monomind-browse-${process.pid}-cccccccc`, { young: true });
|
|
180
|
+
const pid = await deadPid();
|
|
181
|
+
expect(await removeOwnedProfileDir(dir, { root, pid })).toBe(true);
|
|
182
|
+
expect(existsSync(dir)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.skipIf(process.platform !== 'linux')(
|
|
186
|
+
'keeps an owned dir while a live process still uses it',
|
|
187
|
+
async () => {
|
|
188
|
+
const dir = await profile(`monomind-browse-${process.pid}-dddddddd`, { young: true });
|
|
189
|
+
const child = await holder(dir);
|
|
190
|
+
expect(await isProfileDirInUse(dir)).toBe(true);
|
|
191
|
+
expect(await removeOwnedProfileDir(dir, { root, pid: child.pid })).toBe(false);
|
|
192
|
+
expect(existsSync(dir)).toBe(true);
|
|
193
|
+
},
|
|
194
|
+
15_000,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest globalSetup: run every monobrowse test worker against a throwaway
|
|
3
|
+
* TMPDIR (#395).
|
|
4
|
+
*
|
|
5
|
+
* launchBrowser() sweeps stale `monomind-browser-*` / `monomind-browse-*`
|
|
6
|
+
* profile dirs out of os.tmpdir() on every launch. The sweep only removes
|
|
7
|
+
* dead, idle, unused dirs, but a test run must never touch the real /tmp at
|
|
8
|
+
* all: other sessions' live Chrome profiles live there. Set in the main
|
|
9
|
+
* process before any worker starts, so workers and the CLI processes tests
|
|
10
|
+
* spawn inherit it.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export default function setup(): () => void {
|
|
17
|
+
const saved = process.env.TMPDIR;
|
|
18
|
+
// Short on purpose: real-Chrome tests nest their own dirs under it, and
|
|
19
|
+
// Chrome aborts at startup (SIGABRT) once the unix socket path it makes in
|
|
20
|
+
// TMPDIR (…/org.chromium.Chromium.XXXXXX/SingletonSocket) passes ~107 bytes.
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'mb-'));
|
|
22
|
+
process.env.TMPDIR = dir;
|
|
23
|
+
return () => {
|
|
24
|
+
if (saved === undefined) delete process.env.TMPDIR;
|
|
25
|
+
else process.env.TMPDIR = saved;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { CdpClient } from './cdp.js';
|
|
2
|
+
|
|
3
|
+
export async function pushState(client: CdpClient, sessionId: string, url: string): Promise<void> {
|
|
4
|
+
// Try Next.js router first, then fallback to history.pushState
|
|
5
|
+
await evaluateJs(
|
|
6
|
+
client,
|
|
7
|
+
sessionId,
|
|
8
|
+
`
|
|
9
|
+
(function() {
|
|
10
|
+
const url = ${JSON.stringify(url)};
|
|
11
|
+
if (window.next && window.next.router) {
|
|
12
|
+
window.next.router.push(url).catch(function() {});
|
|
13
|
+
return;
|
|
14
|
+
} else {
|
|
15
|
+
history.pushState({}, '', url);
|
|
16
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
19
|
+
`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function addInitScript(
|
|
24
|
+
client: CdpClient,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
script: string,
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const result = await client.send<{ identifier: string }>(
|
|
29
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
30
|
+
{
|
|
31
|
+
source: script,
|
|
32
|
+
},
|
|
33
|
+
sessionId,
|
|
34
|
+
);
|
|
35
|
+
return result.identifier;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function removeInitScript(
|
|
39
|
+
client: CdpClient,
|
|
40
|
+
sessionId: string,
|
|
41
|
+
identifier: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
await client.send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, sessionId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default cap on how long an evaluateJs() call waits for Runtime.evaluate to
|
|
47
|
+
// resolve. Because this always passes `awaitPromise: true`, an expression
|
|
48
|
+
// like `new Promise(() => {})` never settles — the underlying CdpClient.send()
|
|
49
|
+
// has no timeout of its own (the CDP_RESPONSE_SIZE_LIMIT in cdp.ts only guards
|
|
50
|
+
// the HTTP /json/* endpoints, not WebSocket command responses), so without
|
|
51
|
+
// this the caller (e.g. the `eval` CLI command) would hang forever with
|
|
52
|
+
// nothing to kill it.
|
|
53
|
+
const DEFAULT_EVAL_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
export async function evaluateJs(
|
|
56
|
+
client: CdpClient,
|
|
57
|
+
sessionId: string,
|
|
58
|
+
expression: string,
|
|
59
|
+
timeoutMs: number = DEFAULT_EVAL_TIMEOUT_MS,
|
|
60
|
+
): Promise<unknown> {
|
|
61
|
+
const evalPromise = client.send<{
|
|
62
|
+
result: { value?: unknown; type: string; description?: string };
|
|
63
|
+
exceptionDetails?: { text: string; exception?: { description?: string } };
|
|
64
|
+
}>(
|
|
65
|
+
'Runtime.evaluate',
|
|
66
|
+
{
|
|
67
|
+
expression,
|
|
68
|
+
returnByValue: true,
|
|
69
|
+
awaitPromise: true,
|
|
70
|
+
},
|
|
71
|
+
sessionId,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Not unref'd (same rule as CdpClient.send): this timer settles the awaited
|
|
75
|
+
// race, so it has to hold the event loop open while the evaluation is in
|
|
76
|
+
// flight. Cleared in the finally so a fast evaluation does not keep the
|
|
77
|
+
// process alive for the rest of timeoutMs.
|
|
78
|
+
let evalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
let result: Awaited<typeof evalPromise>;
|
|
80
|
+
try {
|
|
81
|
+
result = await (timeoutMs > 0
|
|
82
|
+
? Promise.race([
|
|
83
|
+
evalPromise,
|
|
84
|
+
new Promise<never>((_, reject) => {
|
|
85
|
+
evalTimer = setTimeout(
|
|
86
|
+
() => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)),
|
|
87
|
+
timeoutMs,
|
|
88
|
+
);
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
: evalPromise);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(evalTimer);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (result.exceptionDetails) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`JS evaluation error: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result.result?.value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function readClipboard(client: CdpClient, sessionId: string): Promise<string> {
|
|
106
|
+
const result = await evaluateJs(client, sessionId, 'navigator.clipboard.readText()');
|
|
107
|
+
return result as string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeClipboard(
|
|
111
|
+
client: CdpClient,
|
|
112
|
+
sessionId: string,
|
|
113
|
+
text: string,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
// Cap to 100 KB to prevent OOM when serializing the CDP expression
|
|
116
|
+
const safeText = text.length > 102_400 ? text.slice(0, 102_400) : text;
|
|
117
|
+
await evaluateJs(client, sessionId, `navigator.clipboard.writeText(${JSON.stringify(safeText)})`);
|
|
118
|
+
}
|