browser-commander 0.31.1 → 0.32.0
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/CHANGELOG.md +14 -0
- package/README.md +26 -0
- package/package.json +1 -1
- package/src/browser/browser-handoff.js +304 -0
- package/src/browser/cdp-browser-endpoint.js +55 -0
- package/src/browser/connection-stage.js +23 -0
- package/src/browser/connector.js +29 -15
- package/src/browser/launch-diagnostics.js +4 -1
- package/src/browser/launcher.js +8 -0
- package/src/browser/persistent-session.js +5 -56
- package/src/browser/real-browser.js +56 -5
- package/src/browser/session-persistence.js +9 -5
- package/src/browser/webdriver.js +8 -1
- package/src/core/engine-adapter.js +0 -7
- package/src/elements/locators.js +1 -10
- package/src/elements/selectors.js +10 -155
- package/src/elements/text-selector.js +23 -0
- package/src/exports.js +6 -0
- package/src/utilities/subprocess.js +227 -38
- package/tests/e2e/browser-handoff.e2e.test.js +94 -0
- package/tests/e2e/selector-identity.e2e.test.js +104 -0
- package/tests/e2e/startup-readiness.e2e.test.js +95 -0
- package/tests/helpers/fake-cdp-endpoint.js +64 -0
- package/tests/helpers/temp-directory.js +25 -0
- package/tests/unit/browser/browser-handoff.test.js +406 -0
- package/tests/unit/browser/connection-stage.test.js +74 -0
- package/tests/unit/browser/launch-diagnostics.test.js +23 -1
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/startup-readiness.test.js +129 -0
- package/tests/unit/elements/selector-identity.test.js +50 -0
- package/tests/unit/elements/selectors.test.js +12 -26
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +39 -13
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { constants as osConstants } from 'node:os';
|
|
1
2
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
2
3
|
|
|
3
4
|
/** Start an explicitly persistent process, with no parent-owned pipes or signal forwarding. */
|
|
@@ -8,15 +9,7 @@ export async function startDetachedProcess(file, args = [], options = {}) {
|
|
|
8
9
|
runner.child.once('spawn', resolve);
|
|
9
10
|
runner.child.once('error', reject);
|
|
10
11
|
});
|
|
11
|
-
const child = runner
|
|
12
|
-
// command-stream starts noninteractive children in their own process
|
|
13
|
-
// group. Release its controller-owned cancellation registry while retaining
|
|
14
|
-
// the child's exit listeners. Keep this adapter isolated until it exposes a
|
|
15
|
-
// public detach operation; normal subprocesses retain managed cancellation.
|
|
16
|
-
runner._child = null;
|
|
17
|
-
runner._abortController = null;
|
|
18
|
-
runner.finished = true;
|
|
19
|
-
runner._cleanup();
|
|
12
|
+
const child = releaseRunner(runner);
|
|
20
13
|
Promise.resolve(runner).catch(() => {});
|
|
21
14
|
child.stderrTail = '';
|
|
22
15
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -25,11 +18,65 @@ export async function startDetachedProcess(file, args = [], options = {}) {
|
|
|
25
18
|
child.on('error', (error) => {
|
|
26
19
|
child.spawnError = error;
|
|
27
20
|
});
|
|
28
|
-
child
|
|
21
|
+
unrefChild(child);
|
|
22
|
+
return child;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Release a started runner from command-stream's process-wide cancellation
|
|
27
|
+
* registry and return its raw child. command-stream 2.x has no public per-run
|
|
28
|
+
* opt-out: every runner joins a global set whose SIGINT handler forwards the
|
|
29
|
+
* signal to the child's process group. command-stream starts noninteractive
|
|
30
|
+
* children in their own process group, so once released nothing but the caller
|
|
31
|
+
* signals the child. Its exit listeners are retained.
|
|
32
|
+
*/
|
|
33
|
+
function releaseRunner(runner) {
|
|
34
|
+
const child = runner.child;
|
|
35
|
+
runner._child = null;
|
|
36
|
+
runner._abortController = null;
|
|
37
|
+
runner.finished = true;
|
|
38
|
+
runner._cleanup();
|
|
39
|
+
return child;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unrefChild(child) {
|
|
43
|
+
child.unref?.();
|
|
29
44
|
child.stdout?.unref?.();
|
|
30
45
|
child.stderr?.unref?.();
|
|
31
46
|
child.stdin?.unref?.();
|
|
32
|
-
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Who answers a terminal interrupt (SIGINT) for a started process. */
|
|
50
|
+
export const INTERRUPTION_MODES = Object.freeze(['managed', 'caller']);
|
|
51
|
+
|
|
52
|
+
/** Validate an `interruption` option, returning the effective mode. */
|
|
53
|
+
export function interruptionMode(value = 'managed') {
|
|
54
|
+
if (!INTERRUPTION_MODES.includes(value)) {
|
|
55
|
+
throw new TypeError(
|
|
56
|
+
`interruption must be one of ${INTERRUPTION_MODES.join(', ')}, got ${String(value)}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function signalExitCode(signal) {
|
|
63
|
+
return 128 + (osConstants.signals[signal] ?? 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Signal a process and its process group, ignoring an already-gone one. */
|
|
67
|
+
export function signalProcessTree(pid, signal) {
|
|
68
|
+
let delivered = false;
|
|
69
|
+
for (const target of process.platform === 'win32' ? [pid] : [-pid, pid]) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(target, signal);
|
|
72
|
+
delivered = true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code !== 'ESRCH') {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return delivered;
|
|
33
80
|
}
|
|
34
81
|
|
|
35
82
|
/**
|
|
@@ -128,18 +175,27 @@ export async function runCommand(file, args = [], options = {}) {
|
|
|
128
175
|
* rely on (`pid`, `exitCode`, `kill()`, `stderr`/`stdout` `data` events and an
|
|
129
176
|
* `exit` event), plus an `exited` promise.
|
|
130
177
|
*
|
|
178
|
+
* By default (`interruption: 'managed'`) command-stream owns interruption: its
|
|
179
|
+
* process-wide SIGINT handler forwards Ctrl+C to the child's process group.
|
|
180
|
+
* With `interruption: 'caller'` the child is released from that registry right
|
|
181
|
+
* after it starts, so the embedding program's own SIGINT handler decides
|
|
182
|
+
* whether to stop, detach or keep the child (issue #174). Output streaming,
|
|
183
|
+
* `exited`, and `kill()` (which still awaits the actual exit) keep working.
|
|
184
|
+
*
|
|
131
185
|
* @param {string} file - Executable path
|
|
132
186
|
* @param {string[]} args - Exact arguments
|
|
133
187
|
* @param {Object} [options]
|
|
134
188
|
* @param {Object<string,string>} [options.env] - Environment for the child only
|
|
135
189
|
* @param {boolean} [options.forwardOutput=false] - Mirror the child's output to this process
|
|
136
190
|
* @param {number} [options.killGrace=2000] - Milliseconds between SIGTERM and SIGKILL
|
|
191
|
+
* @param {'managed'|'caller'} [options.interruption='managed'] - Who answers SIGINT
|
|
137
192
|
* @returns {ManagedProcess}
|
|
138
193
|
*/
|
|
139
194
|
export function startProcess(file, args = [], options = {}) {
|
|
140
195
|
const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
|
|
196
|
+
const interruption = interruptionMode(options.interruption);
|
|
141
197
|
const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
|
|
142
|
-
return new ManagedProcess(runner, { forwardOutput });
|
|
198
|
+
return new ManagedProcess(runner, { forwardOutput, killGrace, interruption });
|
|
143
199
|
}
|
|
144
200
|
|
|
145
201
|
class OutputChannel {
|
|
@@ -168,7 +224,10 @@ class OutputChannel {
|
|
|
168
224
|
|
|
169
225
|
/** Handle for a process started by {@link startProcess}. */
|
|
170
226
|
export class ManagedProcess {
|
|
171
|
-
constructor(
|
|
227
|
+
constructor(
|
|
228
|
+
runner,
|
|
229
|
+
{ forwardOutput, killGrace = 2000, interruption = 'managed' }
|
|
230
|
+
) {
|
|
172
231
|
this.runner = runner;
|
|
173
232
|
this.exitCode = null;
|
|
174
233
|
this.signalCode = null;
|
|
@@ -177,38 +236,121 @@ export class ManagedProcess {
|
|
|
177
236
|
this.stdout = new OutputChannel();
|
|
178
237
|
this.stderr = new OutputChannel();
|
|
179
238
|
this.exitListeners = new Set();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
runner.on('
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
this.stderr.emit(chunk);
|
|
239
|
+
this.termination = null;
|
|
240
|
+
this.killGrace = killGrace;
|
|
241
|
+
this.interruption = interruption;
|
|
242
|
+
this.detached = false;
|
|
243
|
+
this.child = null;
|
|
244
|
+
this.forwardOutput = forwardOutput;
|
|
245
|
+
runner.on('stdout', (chunk) => this.receiveStdout(chunk));
|
|
246
|
+
runner.on('stderr', (chunk) => this.receiveStderr(chunk));
|
|
247
|
+
let settle;
|
|
248
|
+
const settled = new Promise((resolve) => {
|
|
249
|
+
settle = resolve;
|
|
192
250
|
});
|
|
251
|
+
this.settle = settle;
|
|
193
252
|
runner.start();
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.then((result) => {
|
|
200
|
-
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
201
|
-
this.signalCode = result.signal ?? null;
|
|
202
|
-
for (const listener of this.exitListeners) {
|
|
203
|
-
listener(this.exitCode);
|
|
253
|
+
Promise.resolve(runner).then(
|
|
254
|
+
(result) => {
|
|
255
|
+
// A released runner resolves without a result; its raw child reports.
|
|
256
|
+
if (!this.child) {
|
|
257
|
+
settle(result);
|
|
204
258
|
}
|
|
205
|
-
|
|
259
|
+
},
|
|
260
|
+
(error) => {
|
|
261
|
+
this.spawnError = error;
|
|
262
|
+
settle({ code: error?.code ?? 1 });
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
this.exited = settled.then(async (result) => {
|
|
266
|
+
const actualExit = await this.termination;
|
|
267
|
+
if (actualExit) {
|
|
268
|
+
result = {
|
|
269
|
+
...result,
|
|
270
|
+
code: actualExit.code ?? result.code,
|
|
271
|
+
signal: actualExit.signal,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
275
|
+
this.signalCode = result.signal ?? null;
|
|
276
|
+
for (const listener of this.exitListeners) {
|
|
277
|
+
listener(this.exitCode);
|
|
278
|
+
}
|
|
279
|
+
return this.exitCode;
|
|
280
|
+
});
|
|
281
|
+
if (interruption === 'caller') {
|
|
282
|
+
this.release();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
receiveStdout(chunk) {
|
|
287
|
+
if (this.forwardOutput) {
|
|
288
|
+
process.stdout.write(chunk);
|
|
289
|
+
}
|
|
290
|
+
this.stdout.emit(chunk);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
receiveStderr(chunk) {
|
|
294
|
+
this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
|
|
295
|
+
if (this.forwardOutput) {
|
|
296
|
+
process.stderr.write(chunk);
|
|
297
|
+
}
|
|
298
|
+
this.stderr.emit(chunk);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Take the child out of command-stream's SIGINT forwarding while this handle
|
|
303
|
+
* keeps observing its output and exit.
|
|
304
|
+
*/
|
|
305
|
+
release() {
|
|
306
|
+
if (this.exitCode !== null || this.termination) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (this.child) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const child = this.runner.child;
|
|
313
|
+
if (!child?.pid) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
releaseRunner(this.runner);
|
|
317
|
+
this.child = child;
|
|
318
|
+
this.interruption = 'caller';
|
|
319
|
+
child.stdout?.on('data', (chunk) => this.receiveStdout(chunk));
|
|
320
|
+
child.stderr?.on('data', (chunk) => this.receiveStderr(chunk));
|
|
321
|
+
const report = (code, signal) =>
|
|
322
|
+
this.settle({
|
|
323
|
+
code: Number.isInteger(code) ? code : signalExitCode(signal),
|
|
324
|
+
signal: signal ?? null,
|
|
206
325
|
});
|
|
326
|
+
if (Number.isInteger(child.exitCode) || child.signalCode) {
|
|
327
|
+
report(child.exitCode, child.signalCode);
|
|
328
|
+
} else {
|
|
329
|
+
child.once('exit', report);
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Leave the process running independently of this program: release it from
|
|
336
|
+
* command-stream's SIGINT forwarding and stop its handle and pipes from
|
|
337
|
+
* keeping the event loop alive. Exit observation keeps working while this
|
|
338
|
+
* program runs, and {@link kill} still stops it. Idempotent.
|
|
339
|
+
*
|
|
340
|
+
* @returns {boolean} Whether a running process is now detached
|
|
341
|
+
*/
|
|
342
|
+
detach() {
|
|
343
|
+
if (!this.release()) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
this.detached = true;
|
|
347
|
+
unrefChild(this.child);
|
|
348
|
+
return true;
|
|
207
349
|
}
|
|
208
350
|
|
|
209
351
|
/** Operating system process id, once spawned. */
|
|
210
352
|
get pid() {
|
|
211
|
-
return this.runner.pid;
|
|
353
|
+
return this.child?.pid ?? this.runner.pid;
|
|
212
354
|
}
|
|
213
355
|
|
|
214
356
|
on(event, listener) {
|
|
@@ -233,10 +375,57 @@ export class ManagedProcess {
|
|
|
233
375
|
* @returns {boolean} Whether a running process was signalled
|
|
234
376
|
*/
|
|
235
377
|
kill(signal) {
|
|
236
|
-
if (this.exitCode !== null) {
|
|
378
|
+
if (this.exitCode !== null || this.termination) {
|
|
237
379
|
return false;
|
|
238
380
|
}
|
|
381
|
+
if (this.child) {
|
|
382
|
+
return this.killReleased(signal ?? 'SIGTERM');
|
|
383
|
+
}
|
|
384
|
+
// command-stream resolves cancellation immediately and removes the child's
|
|
385
|
+
// listeners. Retain its handle and observe the real exit after signalling,
|
|
386
|
+
// so launchers cannot delete profiles while Chrome still writes to them.
|
|
387
|
+
const child = this.runner.child;
|
|
388
|
+
let resolveExit;
|
|
389
|
+
if (child?.pid) {
|
|
390
|
+
this.termination = new Promise((resolve) => {
|
|
391
|
+
resolveExit = resolve;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
239
394
|
this.runner.kill(signal);
|
|
395
|
+
if (resolveExit) {
|
|
396
|
+
if (child.exited) {
|
|
397
|
+
child.exited.then((code) =>
|
|
398
|
+
resolveExit({ code, signal: child.signalCode ?? null })
|
|
399
|
+
);
|
|
400
|
+
} else if (
|
|
401
|
+
Number.isInteger(child.exitCode) ||
|
|
402
|
+
typeof child.signalCode === 'string'
|
|
403
|
+
) {
|
|
404
|
+
resolveExit({ code: child.exitCode, signal: child.signalCode });
|
|
405
|
+
} else {
|
|
406
|
+
child.once('exit', (code, exitSignal) =>
|
|
407
|
+
resolveExit({ code, signal: exitSignal })
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Stop a released child directly; command-stream no longer tracks it. */
|
|
415
|
+
killReleased(signal) {
|
|
416
|
+
const { child } = this;
|
|
417
|
+
if (!signalProcessTree(child.pid, signal)) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
if (signal !== 'SIGKILL') {
|
|
421
|
+
const escalation = setTimeout(() => {
|
|
422
|
+
if (this.exitCode === null) {
|
|
423
|
+
signalProcessTree(child.pid, 'SIGKILL');
|
|
424
|
+
}
|
|
425
|
+
}, this.killGrace);
|
|
426
|
+
escalation.unref?.();
|
|
427
|
+
this.exited.then(() => clearTimeout(escalation));
|
|
428
|
+
}
|
|
240
429
|
return true;
|
|
241
430
|
}
|
|
242
431
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #174: an embedding supervisor owns Ctrl+C for the browser it launched,
|
|
3
|
+
* hands the exact browser and tab to its next run, and that run closes it.
|
|
4
|
+
*/
|
|
5
|
+
// feature-parity: process.interruption@native-typed sessions.handoff@native-typed
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import {
|
|
12
|
+
reconnectBrowser,
|
|
13
|
+
waitForPidExit,
|
|
14
|
+
} from '../../src/browser/browser-handoff.js';
|
|
15
|
+
import { startProcess } from '../../src/utilities/subprocess.js';
|
|
16
|
+
import { repoPath } from '../helpers/repo.js';
|
|
17
|
+
|
|
18
|
+
const SUPERVISOR = repoPath(
|
|
19
|
+
'experiments',
|
|
20
|
+
'issue-174',
|
|
21
|
+
'browser-supervisor.mjs'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Run the supervisor, which interrupts itself after opening a page. */
|
|
25
|
+
async function interruptSupervisor(mode, engine) {
|
|
26
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-e2e-174-'));
|
|
27
|
+
const handoffFile = path.join(directory, 'handoff.json');
|
|
28
|
+
try {
|
|
29
|
+
const supervisor = startProcess(process.execPath, [
|
|
30
|
+
SUPERVISOR,
|
|
31
|
+
mode,
|
|
32
|
+
engine,
|
|
33
|
+
handoffFile,
|
|
34
|
+
]);
|
|
35
|
+
const exitCode = await supervisor.exited;
|
|
36
|
+
const pid = Number(await readFile(`${handoffFile}.pid`, 'utf8'));
|
|
37
|
+
const handoff = await readFile(handoffFile, 'utf8').catch(() => null);
|
|
38
|
+
return { exitCode, pid, handoff, stderr: supervisor.stderrTail };
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(directory, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe(
|
|
45
|
+
'browser hand-off across an embedding supervisor (issue #174)',
|
|
46
|
+
{
|
|
47
|
+
skip:
|
|
48
|
+
(!process.env.RUN_E2E && 'set RUN_E2E=true') ||
|
|
49
|
+
(process.platform === 'win32' && 'SIGINT forwarding is Unix-only'),
|
|
50
|
+
timeout: 120000,
|
|
51
|
+
},
|
|
52
|
+
() => {
|
|
53
|
+
it('by default Ctrl+C reaches Chrome before the embedder can keep it', async () => {
|
|
54
|
+
const { exitCode, pid, handoff } = await interruptSupervisor(
|
|
55
|
+
'managed',
|
|
56
|
+
'playwright'
|
|
57
|
+
);
|
|
58
|
+
assert.equal(exitCode, 130);
|
|
59
|
+
assert.equal(handoff, null);
|
|
60
|
+
assert.equal(await waitForPidExit(pid, 10000), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const engine of ['playwright', 'puppeteer', 'selenium']) {
|
|
64
|
+
it(`${engine}: keeps the exact tab across Ctrl+C and closes it later`, async () => {
|
|
65
|
+
const { exitCode, pid, handoff, stderr } = await interruptSupervisor(
|
|
66
|
+
'caller',
|
|
67
|
+
engine
|
|
68
|
+
);
|
|
69
|
+
assert.equal(exitCode, 130, stderr);
|
|
70
|
+
assert.ok(handoff, stderr);
|
|
71
|
+
const descriptor = JSON.parse(handoff);
|
|
72
|
+
assert.equal(descriptor.pid, pid);
|
|
73
|
+
try {
|
|
74
|
+
assert.equal(await waitForPidExit(pid, 500), false);
|
|
75
|
+
const session = await reconnectBrowser(handoff);
|
|
76
|
+
const title =
|
|
77
|
+
engine === 'selenium'
|
|
78
|
+
? await session.browser.getTitle()
|
|
79
|
+
: await session.page.title();
|
|
80
|
+
assert.equal(title, 'waiting for a human');
|
|
81
|
+
await session.close();
|
|
82
|
+
assert.equal(await waitForPidExit(pid, 1000), true);
|
|
83
|
+
await assert.rejects(readFile(descriptor.userDataDir), {
|
|
84
|
+
code: 'ENOENT',
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
if (!(await waitForPidExit(pid, 0))) {
|
|
88
|
+
process.kill(-pid, 'SIGKILL');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
4
|
+
import { createEngineAdapter } from '../../src/core/engine-adapter.js';
|
|
5
|
+
import { launchE2EBrowser } from '../helpers/e2e-browser.js';
|
|
6
|
+
|
|
7
|
+
for (const engine of ['playwright', 'puppeteer', 'selenium']) {
|
|
8
|
+
it(
|
|
9
|
+
`${engine} keeps scoped text selectors on the intended element`,
|
|
10
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
|
|
11
|
+
async () => {
|
|
12
|
+
const session = await launchE2EBrowser({ engine });
|
|
13
|
+
const commander = makeBrowserCommander({
|
|
14
|
+
page: session.page,
|
|
15
|
+
verbose: false,
|
|
16
|
+
debug: false,
|
|
17
|
+
enableNetworkTracking: false,
|
|
18
|
+
enableNavigationManager: false,
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
await session.page.setContent(`
|
|
22
|
+
<nav><button>A</button><button data-qa='duplicate'
|
|
23
|
+
onclick="window.clicked='nav-2'">B</button></nav>
|
|
24
|
+
<div class="parent"><section><button>A</button>
|
|
25
|
+
<button data-qa='duplicate' data-target='B'
|
|
26
|
+
onclick="window.clicked='B'; this.setAttribute('aria-pressed','true')">B</button>
|
|
27
|
+
<textarea>Edit</textarea>
|
|
28
|
+
</section></div>`);
|
|
29
|
+
for (const exact of [false, true]) {
|
|
30
|
+
const selector = await commander.findByText({
|
|
31
|
+
selector: '.parent button',
|
|
32
|
+
text: 'B',
|
|
33
|
+
exact,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(
|
|
36
|
+
await commander.getAttribute({
|
|
37
|
+
selector,
|
|
38
|
+
attribute: 'data-target',
|
|
39
|
+
}),
|
|
40
|
+
'B'
|
|
41
|
+
);
|
|
42
|
+
assert.equal(await commander.textContent({ selector }), 'B');
|
|
43
|
+
assert.equal(await commander.isVisible({ selector }), true);
|
|
44
|
+
assert.equal(await commander.isEnabled({ selector }), true);
|
|
45
|
+
await commander.clickButton({ selector, verify: false });
|
|
46
|
+
assert.equal(await session.page.evaluate(() => window.clicked), 'B');
|
|
47
|
+
}
|
|
48
|
+
const editable = await commander.findByText({
|
|
49
|
+
selector: '.parent textarea',
|
|
50
|
+
text: 'Edit',
|
|
51
|
+
exact: true,
|
|
52
|
+
});
|
|
53
|
+
await commander.fillTextArea({
|
|
54
|
+
selector: editable,
|
|
55
|
+
text: 'Changed',
|
|
56
|
+
checkEmpty: false,
|
|
57
|
+
simulateTyping: false,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(
|
|
60
|
+
await session.page.evaluate(
|
|
61
|
+
() => document.querySelector('.parent textarea').value
|
|
62
|
+
),
|
|
63
|
+
'Changed'
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
await commander.destroy();
|
|
67
|
+
await session.cleanup();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it(
|
|
74
|
+
'Playwright preserves CSS sibling position across multiple parents',
|
|
75
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
|
|
76
|
+
async () => {
|
|
77
|
+
const session = await launchE2EBrowser({ engine: 'playwright' });
|
|
78
|
+
const commander = makeBrowserCommander({
|
|
79
|
+
page: session.page,
|
|
80
|
+
debug: false,
|
|
81
|
+
enableNetworkTracking: false,
|
|
82
|
+
enableNavigationManager: false,
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await session.page.setContent(
|
|
86
|
+
'<section><span></span><button>A</button></section><section><button>B</button><button>C</button></section>'
|
|
87
|
+
);
|
|
88
|
+
const matches = await commander.querySelectorAll({
|
|
89
|
+
selector: 'section button:nth-of-type(2)',
|
|
90
|
+
});
|
|
91
|
+
assert.equal(matches.length, 1);
|
|
92
|
+
assert.equal(await matches[0].textContent(), 'C');
|
|
93
|
+
const adapter = createEngineAdapter(session.page, 'playwright');
|
|
94
|
+
const adapterMatches = await adapter.querySelectorAll(
|
|
95
|
+
'section button:nth-of-type(2)'
|
|
96
|
+
);
|
|
97
|
+
assert.equal(adapterMatches.length, 1);
|
|
98
|
+
assert.equal(await adapterMatches[0].textContent(), 'C');
|
|
99
|
+
} finally {
|
|
100
|
+
await commander.destroy();
|
|
101
|
+
await session.cleanup();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
7
|
+
import { launchAndConnectRealBrowserWithDependencies } from '../../src/browser/real-browser.js';
|
|
8
|
+
import { startProcess } from '../../src/utilities/subprocess.js';
|
|
9
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
10
|
+
|
|
11
|
+
it(
|
|
12
|
+
'fresh-profile Chrome starts repeatedly with session persistence',
|
|
13
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
|
|
14
|
+
async () => {
|
|
15
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-startup-smoke-'));
|
|
16
|
+
try {
|
|
17
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
18
|
+
const userDataDir = path.join(root, `profile-${attempt}`);
|
|
19
|
+
const session = await launchBrowser({
|
|
20
|
+
debug: false,
|
|
21
|
+
channel: 'chrome',
|
|
22
|
+
headless: true,
|
|
23
|
+
userDataDir,
|
|
24
|
+
persistSessionCookies: true,
|
|
25
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
assert.equal(await session.page.evaluate(() => 2 + 2), 4);
|
|
29
|
+
} finally {
|
|
30
|
+
await session.close();
|
|
31
|
+
}
|
|
32
|
+
assert.notEqual(session.browserProcess.exitCode, null);
|
|
33
|
+
await access(userDataDir);
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
it(
|
|
42
|
+
'failure after real DevTools readiness leaves another Chrome and caller profile intact',
|
|
43
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
|
|
44
|
+
async () => {
|
|
45
|
+
const sibling = await launchBrowser({
|
|
46
|
+
debug: false,
|
|
47
|
+
headless: true,
|
|
48
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
49
|
+
});
|
|
50
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-owned-startup-'));
|
|
51
|
+
const marker = path.join(root, 'keep.txt');
|
|
52
|
+
await writeFile(marker, 'caller profile');
|
|
53
|
+
try {
|
|
54
|
+
for (const suppliedProfile of [false, true]) {
|
|
55
|
+
let child, profile;
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
() =>
|
|
58
|
+
launchAndConnectRealBrowserWithDependencies(
|
|
59
|
+
{
|
|
60
|
+
debug: false,
|
|
61
|
+
headless: true,
|
|
62
|
+
persistSessionCookies: suppliedProfile,
|
|
63
|
+
...(suppliedProfile ? { userDataDir: root } : {}),
|
|
64
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
spawnBrowser: (file, args, options) => {
|
|
68
|
+
profile = args
|
|
69
|
+
.find((arg) => arg.startsWith('--user-data-dir='))
|
|
70
|
+
.slice('--user-data-dir='.length);
|
|
71
|
+
child = startProcess(file, args, { env: options.env });
|
|
72
|
+
return child;
|
|
73
|
+
},
|
|
74
|
+
connect: async () => {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Injected attachment timeout after endpoint readiness'
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
/Injected attachment timeout/
|
|
82
|
+
);
|
|
83
|
+
assert.notEqual(child.exitCode, null);
|
|
84
|
+
assert.equal(await sibling.page.evaluate(() => 2 + 2), 4);
|
|
85
|
+
assert.equal(await readFile(marker, 'utf8'), 'caller profile');
|
|
86
|
+
if (!suppliedProfile) {
|
|
87
|
+
await assert.rejects(() => access(profile), /ENOENT/);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await sibling.close();
|
|
92
|
+
await rm(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake DevTools HTTP endpoint for hand-off tests (issue #174): it answers
|
|
3
|
+
* `/json/version` with a browser WebSocket URL and accepts `Browser.close`
|
|
4
|
+
* on that WebSocket, so detach/reconnect/close run without a browser.
|
|
5
|
+
*/
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { acceptUpgrade } from '../../src/browser/attach/websocket.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.browserId='browser-1'] - Identity in webSocketDebuggerUrl
|
|
12
|
+
* @param {Function} [options.onBrowserClose] - Called when Browser.close arrives
|
|
13
|
+
* @returns {Promise<Object>} `{ endpoint, webSocketDebuggerUrl, closes, setBrowserId, close }`
|
|
14
|
+
*/
|
|
15
|
+
export async function startFakeCdpEndpoint({
|
|
16
|
+
browserId = 'browser-1',
|
|
17
|
+
onBrowserClose = () => {},
|
|
18
|
+
} = {}) {
|
|
19
|
+
const closes = [];
|
|
20
|
+
let currentId = browserId;
|
|
21
|
+
const server = http.createServer((request, response) => {
|
|
22
|
+
if (request.url !== '/json/version') {
|
|
23
|
+
response.writeHead(404).end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
response.setHeader('content-type', 'application/json');
|
|
27
|
+
response.end(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
Browser: 'FakeChrome/1.0',
|
|
30
|
+
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/${currentId}`,
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
35
|
+
const { port } = server.address();
|
|
36
|
+
server.on('upgrade', (request, socket, head) => {
|
|
37
|
+
const connection = acceptUpgrade(request, socket, head);
|
|
38
|
+
connection?.on('message', (text) => {
|
|
39
|
+
const message = JSON.parse(text);
|
|
40
|
+
if (message.method === 'Browser.close') {
|
|
41
|
+
closes.push(request.url);
|
|
42
|
+
connection.send(JSON.stringify({ id: message.id, result: {} }));
|
|
43
|
+
onBrowserClose();
|
|
44
|
+
socket.end();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
endpoint: `http://127.0.0.1:${port}`,
|
|
50
|
+
get webSocketDebuggerUrl() {
|
|
51
|
+
return `ws://127.0.0.1:${port}/devtools/browser/${currentId}`;
|
|
52
|
+
},
|
|
53
|
+
closes,
|
|
54
|
+
/** Pretend another browser now listens on the same port. */
|
|
55
|
+
setBrowserId: (id) => {
|
|
56
|
+
currentId = id;
|
|
57
|
+
},
|
|
58
|
+
close: () =>
|
|
59
|
+
new Promise((resolve) => {
|
|
60
|
+
server.closeAllConnections?.();
|
|
61
|
+
server.close(() => resolve());
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|