@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.
Files changed (101) hide show
  1. package/dist/src/__tests__/disconnect-session.test.d.ts +13 -0
  2. package/dist/src/__tests__/disconnect-session.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/disconnect-session.test.js +37 -0
  4. package/dist/src/__tests__/disconnect-session.test.js.map +1 -0
  5. package/dist/src/__tests__/profile-cleanup-close.test.d.ts +16 -0
  6. package/dist/src/__tests__/profile-cleanup-close.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/profile-cleanup-close.test.js +250 -0
  8. package/dist/src/__tests__/profile-cleanup-close.test.js.map +1 -0
  9. package/dist/src/__tests__/profile-dir.test.d.ts +10 -0
  10. package/dist/src/__tests__/profile-dir.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/profile-dir.test.js +160 -0
  12. package/dist/src/__tests__/profile-dir.test.js.map +1 -0
  13. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts +2 -0
  14. package/dist/src/__tests__/setup/isolated-tmpdir.global.d.ts.map +1 -0
  15. package/dist/src/__tests__/setup/isolated-tmpdir.global.js +30 -0
  16. package/dist/src/__tests__/setup/isolated-tmpdir.global.js.map +1 -0
  17. package/dist/src/browser/actions-eval.d.ts +8 -0
  18. package/dist/src/browser/actions-eval.d.ts.map +1 -0
  19. package/dist/src/browser/actions-eval.js +72 -0
  20. package/dist/src/browser/actions-eval.js.map +1 -0
  21. package/dist/src/browser/actions-form.d.ts +10 -0
  22. package/dist/src/browser/actions-form.d.ts.map +1 -0
  23. package/dist/src/browser/actions-form.js +133 -0
  24. package/dist/src/browser/actions-form.js.map +1 -0
  25. package/dist/src/browser/actions-keyboard.d.ts +7 -0
  26. package/dist/src/browser/actions-keyboard.d.ts.map +1 -0
  27. package/dist/src/browser/actions-keyboard.js +131 -0
  28. package/dist/src/browser/actions-keyboard.js.map +1 -0
  29. package/dist/src/browser/actions-mouse.d.ts +11 -0
  30. package/dist/src/browser/actions-mouse.d.ts.map +1 -0
  31. package/dist/src/browser/actions-mouse.js +105 -0
  32. package/dist/src/browser/actions-mouse.js.map +1 -0
  33. package/dist/src/browser/actions.d.ts +12 -28
  34. package/dist/src/browser/actions.d.ts.map +1 -1
  35. package/dist/src/browser/actions.js +12 -434
  36. package/dist/src/browser/actions.js.map +1 -1
  37. package/dist/src/browser/browser-discovery.d.ts +26 -0
  38. package/dist/src/browser/browser-discovery.d.ts.map +1 -0
  39. package/dist/src/browser/browser-discovery.js +113 -0
  40. package/dist/src/browser/browser-discovery.js.map +1 -0
  41. package/dist/src/browser/browser-launch.d.ts +3 -0
  42. package/dist/src/browser/browser-launch.d.ts.map +1 -0
  43. package/dist/src/browser/browser-launch.js +341 -0
  44. package/dist/src/browser/browser-launch.js.map +1 -0
  45. package/dist/src/browser/browser-lifecycle.d.ts +57 -0
  46. package/dist/src/browser/browser-lifecycle.d.ts.map +1 -0
  47. package/dist/src/browser/browser-lifecycle.js +245 -0
  48. package/dist/src/browser/browser-lifecycle.js.map +1 -0
  49. package/dist/src/browser/browser-page.d.ts +13 -0
  50. package/dist/src/browser/browser-page.d.ts.map +1 -0
  51. package/dist/src/browser/browser-page.js +179 -0
  52. package/dist/src/browser/browser-page.js.map +1 -0
  53. package/dist/src/browser/browser-state.d.ts +11 -0
  54. package/dist/src/browser/browser-state.d.ts.map +1 -0
  55. package/dist/src/browser/browser-state.js +27 -0
  56. package/dist/src/browser/browser-state.js.map +1 -0
  57. package/dist/src/browser/browser.d.ts +5 -68
  58. package/dist/src/browser/browser.d.ts.map +1 -1
  59. package/dist/src/browser/browser.js +9 -849
  60. package/dist/src/browser/browser.js.map +1 -1
  61. package/dist/src/browser/profile-dir.d.ts +88 -0
  62. package/dist/src/browser/profile-dir.d.ts.map +1 -0
  63. package/dist/src/browser/profile-dir.js +242 -0
  64. package/dist/src/browser/profile-dir.js.map +1 -0
  65. package/dist/src/browser/ref-cache.d.ts +4 -0
  66. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  67. package/dist/src/browser/ref-cache.js +2 -0
  68. package/dist/src/browser/ref-cache.js.map +1 -1
  69. package/dist/src/browser/types.d.ts +6 -0
  70. package/dist/src/browser/types.d.ts.map +1 -1
  71. package/dist/src/browser/types.js.map +1 -1
  72. package/dist/src/cli/commands.d.ts +1 -0
  73. package/dist/src/cli/commands.d.ts.map +1 -1
  74. package/dist/src/cli/commands.js +2 -0
  75. package/dist/src/cli/commands.js.map +1 -1
  76. package/dist/src/cli/session.d.ts +9 -0
  77. package/dist/src/cli/session.d.ts.map +1 -1
  78. package/dist/src/cli/session.js +21 -4
  79. package/dist/src/cli/session.js.map +1 -1
  80. package/dist/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +1 -1
  82. package/src/__tests__/disconnect-session.test.ts +42 -0
  83. package/src/__tests__/profile-cleanup-close.test.ts +285 -0
  84. package/src/__tests__/profile-dir.test.ts +196 -0
  85. package/src/__tests__/setup/isolated-tmpdir.global.ts +28 -0
  86. package/src/browser/actions-eval.ts +118 -0
  87. package/src/browser/actions-form.ts +218 -0
  88. package/src/browser/actions-keyboard.ts +171 -0
  89. package/src/browser/actions-mouse.ts +212 -0
  90. package/src/browser/actions.ts +37 -712
  91. package/src/browser/browser-discovery.ts +116 -0
  92. package/src/browser/browser-launch.ts +375 -0
  93. package/src/browser/browser-lifecycle.ts +248 -0
  94. package/src/browser/browser-page.ts +213 -0
  95. package/src/browser/browser-state.ts +30 -0
  96. package/src/browser/browser.ts +21 -917
  97. package/src/browser/profile-dir.ts +249 -0
  98. package/src/browser/ref-cache.ts +7 -0
  99. package/src/browser/types.ts +6 -0
  100. package/src/cli/commands.ts +2 -0
  101. package/src/cli/session.ts +22 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monobrowse",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Native browser automation via Chrome DevTools Protocol — the engine powering monomind browse",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }