@monoes/monobrowse 1.0.25 → 1.0.27
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__/browser-launch.test.d.ts +3 -2
- package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -1
- package/dist/src/__tests__/browser-launch.test.js +71 -6
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- package/dist/src/__tests__/launch-close-settle.test.d.ts +6 -0
- package/dist/src/__tests__/launch-close-settle.test.d.ts.map +1 -1
- package/dist/src/__tests__/launch-close-settle.test.js +29 -8
- package/dist/src/__tests__/launch-close-settle.test.js.map +1 -1
- 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 +324 -0
- package/dist/src/browser/browser-launch.js.map +1 -0
- package/dist/src/browser/browser-lifecycle.d.ts +52 -0
- package/dist/src/browser/browser-lifecycle.d.ts.map +1 -0
- package/dist/src/browser/browser-lifecycle.js +233 -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 +7 -0
- package/dist/src/browser/browser-state.d.ts.map +1 -0
- package/dist/src/browser/browser-state.js +19 -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 -794
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/browser-launch.test.ts +78 -6
- package/src/__tests__/launch-close-settle.test.ts +32 -10
- 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 +358 -0
- package/src/browser/browser-lifecycle.ts +238 -0
- package/src/browser/browser-page.ts +213 -0
- package/src/browser/browser-state.ts +21 -0
- package/src/browser/browser.ts +17 -859
package/package.json
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* launchBrowser would ever exec a browser binary, so these run fast and
|
|
6
6
|
* don't depend on Chrome being installed in CI.
|
|
7
7
|
*
|
|
8
|
-
* Fixed port range (
|
|
9
|
-
* services
|
|
8
|
+
* Fixed port range (23460-23489) chosen to avoid colliding with real
|
|
9
|
+
* services and with reap-idle-browser.test.ts's 23490-23499, which runs in a
|
|
10
|
+
* parallel worker; each test binds/tears down its own listeners.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
15
16
|
import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
|
|
16
17
|
import { tmpdir } from 'node:os';
|
|
@@ -310,25 +311,33 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
310
311
|
// port too — see cli/commands.ts's `port` option default). If the bind
|
|
311
312
|
// fails (another process already has it), it exits the way real Chrome does
|
|
312
313
|
// when a concurrent launch wins the same "free-looking" port: before its
|
|
313
|
-
// CDP endpoint ever opens.
|
|
314
|
+
// CDP endpoint ever opens. Like real Chrome on a fixed port, it writes no
|
|
315
|
+
// DevToolsActivePort; it announces its browser websocket on stderr, with an
|
|
316
|
+
// id unique to this process that /json/version reports too.
|
|
314
317
|
const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
|
|
315
318
|
${FIXTURE_WATCHDOG}const { createServer } = require('node:http');
|
|
319
|
+
const { randomUUID } = require('node:crypto');
|
|
316
320
|
const { writeFileSync, mkdirSync } = require('node:fs');
|
|
317
321
|
const { join } = require('node:path');
|
|
318
322
|
const port = Number(
|
|
319
323
|
process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
|
|
320
324
|
);
|
|
321
325
|
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
326
|
+
const ws = 'ws://127.0.0.1:' + port + '/devtools/browser/' + randomUUID();
|
|
322
327
|
const server = createServer((req, res) => {
|
|
323
328
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
324
|
-
res.end(
|
|
329
|
+
res.end(
|
|
330
|
+
req.url === '/json/version'
|
|
331
|
+
? JSON.stringify({ Browser: 'Chrome/999.0.0.0', webSocketDebuggerUrl: ws })
|
|
332
|
+
: '[]',
|
|
333
|
+
);
|
|
325
334
|
});
|
|
326
335
|
server.on('error', () => {
|
|
327
336
|
process.exit(21);
|
|
328
337
|
});
|
|
329
338
|
server.listen(port, '127.0.0.1', () => {
|
|
330
339
|
mkdirSync(dir, { recursive: true });
|
|
331
|
-
|
|
340
|
+
process.stderr.write('\\nDevTools listening on ' + ws + '\\n');
|
|
332
341
|
});
|
|
333
342
|
`;
|
|
334
343
|
|
|
@@ -385,5 +394,68 @@ describe.skipIf(process.platform === 'win32')(
|
|
|
385
394
|
expect(dirs[0]).toBeTruthy();
|
|
386
395
|
expect(dirs[1]).toBeTruthy();
|
|
387
396
|
}, 15000);
|
|
397
|
+
|
|
398
|
+
it('a launch whose Chrome is still booting does not adopt a concurrent launch that took the port', async () => {
|
|
399
|
+
// The loaded-machine shape of the race above, made deterministic: both
|
|
400
|
+
// launches probe the port as free, the "fast" Chrome binds it first,
|
|
401
|
+
// and the "slow" one is still starting when its poll finds a Chrome
|
|
402
|
+
// answering there. That Chrome is not the slow launch's own, so it
|
|
403
|
+
// must not be returned — nor may the slow launch record its own pid
|
|
404
|
+
// (about to exit on the lost bind) under the fast launch's port.
|
|
405
|
+
const dir = fixtureDir('monobrowse-adopt-');
|
|
406
|
+
const fast = join(dir, 'fast-chrome.cjs');
|
|
407
|
+
const slow = join(dir, 'slow-chrome.cjs');
|
|
408
|
+
writeFileSync(fast, fixedPortChrome(500));
|
|
409
|
+
writeFileSync(slow, fixedPortChrome(2500));
|
|
410
|
+
chmodSync(fast, 0o755);
|
|
411
|
+
chmodSync(slow, 0o755);
|
|
412
|
+
// Below BASE: this file's fixed ports, clear of the kernel's
|
|
413
|
+
// ephemeral range, where another test's listen(0) server can land on
|
|
414
|
+
// the candidate the slow launch scans on to.
|
|
415
|
+
const port = BASE - 10;
|
|
416
|
+
|
|
417
|
+
let results: PromiseSettledResult<number>[] = [];
|
|
418
|
+
try {
|
|
419
|
+
results = await Promise.allSettled([
|
|
420
|
+
launchBrowser({ port, executablePath: fast, launchTimeoutMs: 10_000 }),
|
|
421
|
+
launchBrowser({ port, executablePath: slow, launchTimeoutMs: 10_000 }),
|
|
422
|
+
]);
|
|
423
|
+
} finally {
|
|
424
|
+
for (const r of results) {
|
|
425
|
+
const pid = r.status === 'fulfilled' ? getLaunchedPid(r.value) : undefined;
|
|
426
|
+
if (pid) {
|
|
427
|
+
try {
|
|
428
|
+
process.kill(pid, 'SIGKILL');
|
|
429
|
+
} catch {
|
|
430
|
+
/* already gone */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const [fastResult, slowResult] = results;
|
|
437
|
+
expect(fastResult).toEqual({ status: 'fulfilled', value: port });
|
|
438
|
+
if (slowResult.status === 'rejected') throw slowResult.reason;
|
|
439
|
+
expect(slowResult.value).not.toBe(port);
|
|
440
|
+
// The pid tracked for the port is the Chrome that actually serves it.
|
|
441
|
+
const fastDir = getLaunchedUserDataDir(port)!;
|
|
442
|
+
expect(fastDir).toBeTruthy();
|
|
443
|
+
expect(getLaunchedPid(port)).toBe(Number(readFileSync(join(fastDir, 'pid'), 'utf8')));
|
|
444
|
+
}, 20_000);
|
|
388
445
|
},
|
|
389
446
|
);
|
|
447
|
+
|
|
448
|
+
/** FAKE_CHROME_FIXED_PORT that waits `delayMs` before binding, and records
|
|
449
|
+
* its own pid in its profile dir. */
|
|
450
|
+
function fixedPortChrome(delayMs: number): string {
|
|
451
|
+
return FAKE_CHROME_FIXED_PORT.replace(
|
|
452
|
+
'mkdirSync(dir, { recursive: true });',
|
|
453
|
+
`mkdirSync(dir, { recursive: true });
|
|
454
|
+
writeFileSync(join(dir, 'pid'), String(process.pid));`,
|
|
455
|
+
)
|
|
456
|
+
.replace(
|
|
457
|
+
"server.listen(port, '127.0.0.1', () => {",
|
|
458
|
+
"setTimeout(() => server.listen(port, '127.0.0.1', () => {",
|
|
459
|
+
)
|
|
460
|
+
.replace(/\}\);\n$/, `}), ${delayMs});\n`);
|
|
461
|
+
}
|
|
@@ -27,11 +27,18 @@
|
|
|
27
27
|
* manual repro. vi.useFakeTimers() (used throughout close-browser.test.ts)
|
|
28
28
|
* cannot catch this class of bug, because fake timers do not model real
|
|
29
29
|
* event-loop-drain semantics — hence a dedicated, real-timer test here.
|
|
30
|
+
*
|
|
31
|
+
* Ports come from the OS (listen on 0), never a fixed number (#351): these
|
|
32
|
+
* tests used 23495-23497, inside reap-idle-browser.test.ts's 23490-23499
|
|
33
|
+
* range, whose fake Chrome endpoints run in a parallel worker. A spawn that
|
|
34
|
+
* failed on 23495 while that file had 23495 bound looked like a lost launch
|
|
35
|
+
* race to launchBrowser, which moved on and reported port 23496 instead.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { spawn } from 'node:child_process';
|
|
33
39
|
import { chmodSync, writeFileSync } from 'node:fs';
|
|
34
40
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
41
|
+
import { createServer } from 'node:net';
|
|
35
42
|
import { tmpdir } from 'node:os';
|
|
36
43
|
import { join } from 'node:path';
|
|
37
44
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -88,6 +95,19 @@ afterEach(async () => {
|
|
|
88
95
|
await rm(tempDir, { recursive: true, force: true });
|
|
89
96
|
});
|
|
90
97
|
|
|
98
|
+
/** A port nothing is listening on, picked by the kernel from its ephemeral
|
|
99
|
+
* range — which the fixed-port fixtures of other test files stay below. */
|
|
100
|
+
function freePort(): Promise<number> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = createServer();
|
|
103
|
+
server.once('error', reject);
|
|
104
|
+
server.listen(0, '127.0.0.1', () => {
|
|
105
|
+
const { port } = server.address() as { port: number };
|
|
106
|
+
server.close(() => resolve(port));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
async function writePersistedPort(port: number, pid: number, savedAt: number): Promise<void> {
|
|
92
112
|
const dir = join(tempDir, '.monomind', 'monobrowse');
|
|
93
113
|
await mkdir(dir, { recursive: true });
|
|
@@ -118,21 +138,22 @@ describe('#314: launchBrowser settles instead of hanging when Chrome fails to st
|
|
|
118
138
|
// "file not found" that launchBrowser would reject before ever exec'ing.
|
|
119
139
|
writeFileSync(fake, 'not an executable\n');
|
|
120
140
|
chmodSync(fake, 0o644);
|
|
141
|
+
const port = await freePort();
|
|
121
142
|
|
|
122
143
|
const start = Date.now();
|
|
123
144
|
await expect(
|
|
124
|
-
launchBrowser({ executablePath: fake, port
|
|
125
|
-
).rejects.toThrow(
|
|
145
|
+
launchBrowser({ executablePath: fake, port, launchTimeoutMs: 10_000 }),
|
|
146
|
+
).rejects.toThrow(`Chrome failed to start on port ${port}: `);
|
|
126
147
|
// Without a child 'error' listener, this used to depend entirely on
|
|
127
148
|
// whether something outside this function happened to intercept the
|
|
128
149
|
// resulting uncaught exception (a bare node:test process has nothing
|
|
129
150
|
// that does — see the module doc comment); where it happened not to
|
|
130
151
|
// crash the process outright, the promise still only ever settled by
|
|
131
152
|
// burning the full launchTimeoutMs. The fix rejects as soon as the
|
|
132
|
-
// spawn error fires, so this must land
|
|
133
|
-
// ride
|
|
134
|
-
expect(Date.now() - start).toBeLessThan(
|
|
135
|
-
},
|
|
153
|
+
// spawn error fires, so this must land well inside the 10s timeout
|
|
154
|
+
// rather than ride it out — with room for a loaded machine.
|
|
155
|
+
expect(Date.now() - start).toBeLessThan(5000);
|
|
156
|
+
}, 20_000);
|
|
136
157
|
|
|
137
158
|
it('rejects with a clear message when Chrome exits immediately instead of opening its CDP port', async () => {
|
|
138
159
|
vi.resetModules();
|
|
@@ -140,18 +161,19 @@ describe('#314: launchBrowser settles instead of hanging when Chrome fails to st
|
|
|
140
161
|
const fake = join(tempDir, 'chrome.sh');
|
|
141
162
|
writeFileSync(fake, '#!/bin/sh\nexit 1\n');
|
|
142
163
|
chmodSync(fake, 0o755);
|
|
164
|
+
const port = await freePort();
|
|
143
165
|
|
|
144
166
|
await expect(
|
|
145
|
-
launchBrowser({ executablePath: fake, port
|
|
146
|
-
).rejects.toThrow(
|
|
147
|
-
},
|
|
167
|
+
launchBrowser({ executablePath: fake, port, launchTimeoutMs: 10_000 }),
|
|
168
|
+
).rejects.toThrow(`Chrome exited before the CDP endpoint opened on port ${port} `);
|
|
169
|
+
}, 20_000);
|
|
148
170
|
});
|
|
149
171
|
|
|
150
172
|
describe('#314: closeBrowser settles under the real event loop, not fake timers', () => {
|
|
151
173
|
it('resolves promptly when nothing else is pinning the event loop', async () => {
|
|
152
174
|
vi.resetModules();
|
|
153
175
|
const { closeBrowser } = await import('../browser/browser.js');
|
|
154
|
-
const port =
|
|
176
|
+
const port = await freePort();
|
|
155
177
|
|
|
156
178
|
// A real, short-lived child — not Chrome, just something with a genuine,
|
|
157
179
|
// observable exit so process.kill(pid, 0) behaves for real and there is
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pressKeyCombo, typeText } from './actions-keyboard.js';
|
|
3
|
+
import { clickPoint } from './actions-mouse.js';
|
|
4
|
+
import type { CdpClient } from './cdp.js';
|
|
5
|
+
import { getElementBox, getObjectIdForRef } from './snapshot.js';
|
|
6
|
+
import type { ClickOptions, ElementRef } from './types.js';
|
|
7
|
+
|
|
8
|
+
export async function clickElement(
|
|
9
|
+
client: CdpClient,
|
|
10
|
+
sessionId: string,
|
|
11
|
+
ref: ElementRef,
|
|
12
|
+
options: ClickOptions = {},
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const box = await getElementBox(client, sessionId, ref);
|
|
15
|
+
|
|
16
|
+
if (box) {
|
|
17
|
+
await clickPoint(client, sessionId, box.x, box.y, options);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback: use JS click via objectId
|
|
22
|
+
const objectId = await getObjectIdForRef(client, sessionId, ref);
|
|
23
|
+
if (objectId) {
|
|
24
|
+
await client.send(
|
|
25
|
+
'Runtime.callFunctionOn',
|
|
26
|
+
{
|
|
27
|
+
functionDeclaration: 'function() { this.click(); }',
|
|
28
|
+
objectId,
|
|
29
|
+
returnByValue: true,
|
|
30
|
+
},
|
|
31
|
+
sessionId,
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(`Cannot click ref @${ref.ref}: element not found in DOM`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fillElement(
|
|
40
|
+
client: CdpClient,
|
|
41
|
+
sessionId: string,
|
|
42
|
+
ref: ElementRef,
|
|
43
|
+
value: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const box = await getElementBox(client, sessionId, ref);
|
|
46
|
+
|
|
47
|
+
if (box) {
|
|
48
|
+
// Click to focus
|
|
49
|
+
await clickPoint(client, sessionId, box.x, box.y);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Select all and replace
|
|
53
|
+
const objectId = await getObjectIdForRef(client, sessionId, ref);
|
|
54
|
+
if (objectId) {
|
|
55
|
+
const fillSelectResult = await client.send<{
|
|
56
|
+
result: unknown;
|
|
57
|
+
exceptionDetails?: { text: string; exception?: { description?: string } };
|
|
58
|
+
}>(
|
|
59
|
+
'Runtime.callFunctionOn',
|
|
60
|
+
{
|
|
61
|
+
functionDeclaration: `function() {
|
|
62
|
+
this.focus();
|
|
63
|
+
if (this.tagName === 'INPUT' || this.tagName === 'TEXTAREA') {
|
|
64
|
+
this.select();
|
|
65
|
+
} else if (this.isContentEditable) {
|
|
66
|
+
const range = document.createRange();
|
|
67
|
+
range.selectNodeContents(this);
|
|
68
|
+
const sel = window.getSelection();
|
|
69
|
+
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
70
|
+
}
|
|
71
|
+
}`,
|
|
72
|
+
objectId,
|
|
73
|
+
returnByValue: true,
|
|
74
|
+
},
|
|
75
|
+
sessionId,
|
|
76
|
+
);
|
|
77
|
+
if (fillSelectResult.exceptionDetails) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`fillElement select-all failed: ${fillSelectResult.exceptionDetails.exception?.description ?? fillSelectResult.exceptionDetails.text}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
} else if (box) {
|
|
83
|
+
// Fallback for elements without a resolvable objectId: keyboard select-all clears existing content
|
|
84
|
+
const mod = process.platform === 'darwin' ? 4 : 2;
|
|
85
|
+
await pressKeyCombo(client, sessionId, 'a', mod);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!objectId && !box) throw new Error(`Cannot fill ref @${ref.ref}: element not found in DOM`);
|
|
89
|
+
|
|
90
|
+
// Type the value character by character for natural input
|
|
91
|
+
await typeText(client, sessionId, value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function selectOption(
|
|
95
|
+
client: CdpClient,
|
|
96
|
+
sessionId: string,
|
|
97
|
+
ref: ElementRef,
|
|
98
|
+
value: string,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const objectId = await getObjectIdForRef(client, sessionId, ref);
|
|
101
|
+
if (!objectId) throw new Error(`Cannot select: ref @${ref.ref} not found in DOM`);
|
|
102
|
+
|
|
103
|
+
const selectResult = await client.send<{
|
|
104
|
+
result: unknown;
|
|
105
|
+
exceptionDetails?: { text: string; exception?: { description?: string } };
|
|
106
|
+
}>(
|
|
107
|
+
'Runtime.callFunctionOn',
|
|
108
|
+
{
|
|
109
|
+
functionDeclaration: `function(value) {
|
|
110
|
+
if (this.tagName !== 'SELECT') throw new Error('Not a select element');
|
|
111
|
+
for (const opt of this.options) {
|
|
112
|
+
if (opt.value === value || opt.textContent.trim() === value) {
|
|
113
|
+
this.value = opt.value;
|
|
114
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw new Error('Option not found: ' + value);
|
|
119
|
+
}`,
|
|
120
|
+
objectId,
|
|
121
|
+
arguments: [{ value }],
|
|
122
|
+
returnByValue: true,
|
|
123
|
+
},
|
|
124
|
+
sessionId,
|
|
125
|
+
);
|
|
126
|
+
if (selectResult.exceptionDetails) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`selectOption failed: ${selectResult.exceptionDetails.exception?.description ?? selectResult.exceptionDetails.text}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function checkElement(
|
|
134
|
+
client: CdpClient,
|
|
135
|
+
sessionId: string,
|
|
136
|
+
ref: ElementRef,
|
|
137
|
+
checked = true,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const objectId = await getObjectIdForRef(client, sessionId, ref);
|
|
140
|
+
if (!objectId) throw new Error(`Cannot check: ref @${ref.ref} not found in DOM`);
|
|
141
|
+
|
|
142
|
+
const checkResult = await client.send<{
|
|
143
|
+
result: unknown;
|
|
144
|
+
exceptionDetails?: { text: string; exception?: { description?: string } };
|
|
145
|
+
}>(
|
|
146
|
+
'Runtime.callFunctionOn',
|
|
147
|
+
{
|
|
148
|
+
functionDeclaration: `function(checked) {
|
|
149
|
+
if (this.checked !== checked) {
|
|
150
|
+
this.click();
|
|
151
|
+
}
|
|
152
|
+
}`,
|
|
153
|
+
objectId,
|
|
154
|
+
arguments: [{ value: checked }],
|
|
155
|
+
returnByValue: true,
|
|
156
|
+
},
|
|
157
|
+
sessionId,
|
|
158
|
+
);
|
|
159
|
+
if (checkResult.exceptionDetails) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`checkElement failed: ${checkResult.exceptionDetails.exception?.description ?? checkResult.exceptionDetails.text}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function focusElement(
|
|
167
|
+
client: CdpClient,
|
|
168
|
+
sessionId: string,
|
|
169
|
+
ref: ElementRef,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const objectId = await getObjectIdForRef(client, sessionId, ref);
|
|
172
|
+
if (objectId) {
|
|
173
|
+
await client.send(
|
|
174
|
+
'Runtime.callFunctionOn',
|
|
175
|
+
{
|
|
176
|
+
functionDeclaration: 'function() { this.focus(); }',
|
|
177
|
+
objectId,
|
|
178
|
+
returnByValue: true,
|
|
179
|
+
},
|
|
180
|
+
sessionId,
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const box = await getElementBox(client, sessionId, ref);
|
|
185
|
+
if (box) {
|
|
186
|
+
await clickPoint(client, sessionId, box.x, box.y);
|
|
187
|
+
} else {
|
|
188
|
+
throw new Error(`Cannot focus ref @${ref.ref}: element not found in DOM`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function typeIntoElement(
|
|
193
|
+
client: CdpClient,
|
|
194
|
+
sessionId: string,
|
|
195
|
+
ref: ElementRef,
|
|
196
|
+
text: string,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
await focusElement(client, sessionId, ref);
|
|
199
|
+
await typeText(client, sessionId, text);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function uploadFile(
|
|
203
|
+
client: CdpClient,
|
|
204
|
+
sessionId: string,
|
|
205
|
+
ref: ElementRef,
|
|
206
|
+
filePaths: string[],
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
if (!ref.backendDOMNodeId) throw new Error(`Cannot upload: ref @${ref.ref} has no DOM node`);
|
|
209
|
+
|
|
210
|
+
await client.send(
|
|
211
|
+
'DOM.setFileInputFiles',
|
|
212
|
+
{
|
|
213
|
+
files: filePaths.map((f) => resolve(f)),
|
|
214
|
+
backendNodeId: ref.backendDOMNodeId,
|
|
215
|
+
},
|
|
216
|
+
sessionId,
|
|
217
|
+
);
|
|
218
|
+
}
|