selenium-webdriver 4.47.0 → 4.48.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/CHANGES.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 4.48.0
2
+
3
+ - Support CDP versions: v150, v151, v152
4
+ - Normalize empty custom locator results (#17851)
5
+ - [build] Automated Browser Version Update (major) with CDP (#17910)
6
+ - Ensure BiDi is not exposed on Driver (#17926)
7
+ - Add serialization and domain layer (#17927)
8
+ - [bidi] Add BiDi connection-level event subscription (#17946)
9
+
1
10
  ## 4.47.0
2
11
 
3
12
  - Support CDP versions: v149, v150, v151
package/bidi/domain.js ADDED
@@ -0,0 +1,87 @@
1
+ // Licensed to the Software Freedom Conservancy (SFC) under one
2
+ // or more contributor license agreements. See the NOTICE file
3
+ // distributed with this work for additional information
4
+ // regarding copyright ownership. The SFC licenses this file
5
+ // to you under the Apache License, Version 2.0 (the
6
+ // "License"); you may not use this file except in compliance
7
+ // with the License. You may obtain a copy of the License at
8
+ //
9
+ // http://www.apache.org/licenses/LICENSE-2.0
10
+ //
11
+ // Unless required by applicable law or agreed to in writing,
12
+ // software distributed under the License is distributed on an
13
+ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ // KIND, either express or implied. See the License for the
15
+ // specific language governing permissions and limitations
16
+ // under the License.
17
+
18
+ const { getBidiConnection } = require('../lib/bidi_connection')
19
+
20
+ // Gates Domain's constructor so `new Network(someRandomThing)` fails loudly
21
+ // instead of silently producing a broken instance. A Symbol can't be forged
22
+ // or guessed, so this is real runtime enforcement, not just a TS annotation —
23
+ // only a generated `Class.create(driver)` (and this package's own tests) may
24
+ // pass it. It's exported deliberately, not hidden: the point is to stop
25
+ // accidental misuse of the normal `new Network(x)` shape, not to defend
26
+ // against someone who deliberately imports and passes this.
27
+ const DOMAIN_TOKEN = Symbol('Domain internal construction token — obtained only via Class.create(driver)')
28
+
29
+ /**
30
+ * Describes one subscribable BiDi event, for use with Domain#addCallback().
31
+ * @param {string} method
32
+ * @param {{fromWire(payload: unknown): unknown}} [type] Runtime record/union
33
+ * class for the event's params, if the schema declares one. When present,
34
+ * addCallback() parses each delivered payload through it before the
35
+ * caller's handler runs — inbound wire payloads are validated against
36
+ * their resolved type; an event's params is such a payload just as much
37
+ * as a command's result is.
38
+ * @returns {{method: string, type: ({fromWire(payload: unknown): unknown}|undefined)}}
39
+ * The event descriptor, ready to pass to Domain#addCallback().
40
+ */
41
+ function event(method, type) {
42
+ return { method, type }
43
+ }
44
+
45
+ /** Shared base for every generated BiDi domain class. See domain.d.ts for the typed surface. */
46
+ class Domain {
47
+ #bidi
48
+
49
+ constructor(bidi, token) {
50
+ if (token !== DOMAIN_TOKEN) {
51
+ throw new TypeError(`${new.target.name} must be constructed via ${new.target.name}.create(driver), not \`new\``)
52
+ }
53
+ this.#bidi = bidi
54
+ }
55
+
56
+ static async connect(driver) {
57
+ return getBidiConnection(driver)
58
+ }
59
+
60
+ async send(method, params) {
61
+ const response = await this.#bidi.send({ method, params })
62
+ if (response?.error !== undefined) {
63
+ throw new Error(`${response.error}: ${response.message}`)
64
+ }
65
+ return response?.result
66
+ }
67
+
68
+ /**
69
+ * Subscribes `handler` to a BiDi event. All the actual subscription-lifecycle
70
+ * work — remote subscribe/unsubscribe, per-subscription bookkeeping — lives on
71
+ * the connection itself (see Index#addCallback in bidi/index.js); Domain only
72
+ * adds the one thing the connection can't do on its own: parsing a delivered
73
+ * payload through the descriptor's type before the caller's handler runs.
74
+ * @param {{method: string, type: ({fromWire(payload: unknown): unknown}|undefined)}} descriptor
75
+ * An event descriptor from event().
76
+ * @param {function(unknown): void} handler Invoked with the event's params
77
+ * (parsed through descriptor.type first, if one was given) each time it fires.
78
+ * @returns {Promise<{id: string, unsubscribe: function(): Promise<void>}>}
79
+ * A handle for this subscription — call `unsubscribe()` to stop receiving the event.
80
+ */
81
+ async addCallback(descriptor, handler) {
82
+ const dispatch = descriptor.type === undefined ? handler : (params) => handler(descriptor.type.fromWire(params))
83
+ return this.#bidi.addCallback(descriptor.method, dispatch)
84
+ }
85
+ }
86
+
87
+ module.exports = { Domain, event, DOMAIN_TOKEN }
package/bidi/index.js CHANGED
@@ -36,6 +36,10 @@ class Index extends EventEmitter {
36
36
  this._closed = false
37
37
  this._pending = new Map()
38
38
  this._connectWaiters = new Set()
39
+ // removeCallback(id) only receives the subscriptionId — off() needs the event
40
+ // name and the exact handler function too, so this holds what it needs to
41
+ // detach the right listener without the caller having to keep them around.
42
+ this._callbacks = new Map()
39
43
  this._ws = new WebSocket(_webSocketUrl)
40
44
  this._ws.on('open', () => {
41
45
  // The handshake can complete after close()/_failPending() has already
@@ -71,12 +75,7 @@ class Index extends EventEmitter {
71
75
  } catch (err) {
72
76
  // Surface protocol parse failures rather than silently dropping —
73
77
  // otherwise callers see misleading send() timeouts.
74
- const wrapped = new Error(`Failed to parse BiDi message: ${err.message}`)
75
- if (this.listenerCount('error') > 0) {
76
- this.emit('error', wrapped)
77
- } else {
78
- process.emitWarning(wrapped.message, 'BiDiProtocolWarning')
79
- }
78
+ this._emitOrWarn(new Error(`Failed to parse BiDi message: ${err.message}`), 'BiDiProtocolWarning')
80
79
  return
81
80
  }
82
81
  // Messages without a numeric id are BiDi events, not command responses.
@@ -94,14 +93,31 @@ class Index extends EventEmitter {
94
93
  // method named 'error' through the same guarded path used for JSON
95
94
  // parse failures rather than forwarding it directly.
96
95
  if (payload.method === 'error') {
97
- const err = new Error(`BiDi protocol error event: ${JSON.stringify(payload.params)}`)
98
- if (this.listenerCount('error') > 0) {
99
- this.emit('error', err)
100
- } else {
101
- process.emitWarning(err.message, 'BiDiProtocolWarning')
102
- }
96
+ this._emitOrWarn(
97
+ new Error(`BiDi protocol error event: ${JSON.stringify(payload.params)}`),
98
+ 'BiDiProtocolWarning',
99
+ )
103
100
  } else {
104
- this.emit(payload.method, payload.params)
101
+ // A listener can throw synchronously — most notably a typed
102
+ // addCallback() dispatcher's fromWire() rejecting a corrupted
103
+ // payload, which is meant to error rather than warn. Dispatched
104
+ // one listener at a time (not via a single this.emit() call) so a
105
+ // throwing listener doesn't prevent a sibling listener registered
106
+ // for the same event from still receiving this delivery — emit()
107
+ // itself aborts the rest of its iteration once one listener throws.
108
+ // rawListeners(), not listeners(): listeners() unwraps a once()
109
+ // registration to the caller's original function, so invoking it
110
+ // here directly (bypassing emit()) would skip the internal wrapper
111
+ // that removes it after one call — rawListeners() returns that
112
+ // wrapper itself, preserving once()'s self-removal.
113
+ for (const listener of this.rawListeners(payload.method)) {
114
+ try {
115
+ listener(payload.params)
116
+ } catch (err) {
117
+ const wrapped = err instanceof Error ? err : new Error(String(err))
118
+ this._emitOrWarn(wrapped, 'BiDiEventHandlerWarning')
119
+ }
120
+ }
105
121
  }
106
122
  }
107
123
  return
@@ -147,6 +163,38 @@ class Index extends EventEmitter {
147
163
  reject(error)
148
164
  }
149
165
  this._connectWaiters.clear()
166
+ // Detach every addCallback() listener too. Once closed, removeCallback()
167
+ // can no longer reach the remote end (send() would just throw), so nothing
168
+ // else will ever detach these listeners. Drop them here instead of leaving
169
+ // them attached to an EventEmitter nothing will ever emit on again.
170
+ for (const { method, handler } of this._callbacks.values()) {
171
+ this.off(method, handler)
172
+ }
173
+ this._callbacks.clear()
174
+ }
175
+
176
+ /**
177
+ * Emits `err` as an 'error' event if anything is listening for one,
178
+ * otherwise reports it as a process warning under `warningType` — never
179
+ * emits 'error' with no listener attached, which would itself throw and
180
+ * crash the process. Also guards against the 'error' listener itself
181
+ * throwing, so a broken listener can't cause the exact kind of crash this
182
+ * helper exists to prevent, just one level removed.
183
+ * @param {Error} err
184
+ * @param {string} warningType
185
+ * @private
186
+ */
187
+ _emitOrWarn(err, warningType) {
188
+ if (this.listenerCount('error') === 0) {
189
+ process.emitWarning(err.message, warningType)
190
+ return
191
+ }
192
+ try {
193
+ this.emit('error', err)
194
+ } catch (listenerErr) {
195
+ const wrapped = listenerErr instanceof Error ? listenerErr : new Error(String(listenerErr))
196
+ process.emitWarning(`BiDi 'error' listener threw: ${wrapped.message}`, warningType)
197
+ }
150
198
  }
151
199
 
152
200
  /**
@@ -229,7 +277,17 @@ class Index extends EventEmitter {
229
277
  }
230
278
 
231
279
  /**
232
- * Subscribe to events
280
+ * Subscribe to events.
281
+ *
282
+ * Not the correct implementation — this is not tied to a subscription id, so
283
+ * {@link unsubscribe} below cancels by event/context name and can affect a
284
+ * subscription made elsewhere (including via {@link addCallback}) for the
285
+ * same event. Kept as-is only because the existing hand-written bidi/*.js
286
+ * modules already depend on this exact shape; new code should use
287
+ * {@link addCallback} instead, which is properly scoped by subscription id
288
+ * (mirroring Java's BiDi#addListener/removeListener — see BiDi.java). Once
289
+ * those hand-written modules are replaced by generated code built on
290
+ * addCallback/removeCallback, this method (and unsubscribe) can be removed.
233
291
  * @param events
234
292
  * @param browsingContexts
235
293
  * @returns {Promise<void>}
@@ -273,7 +331,10 @@ class Index extends EventEmitter {
273
331
  }
274
332
 
275
333
  /**
276
- * Unsubscribe to events
334
+ * Unsubscribe to events. See the note on {@link subscribe} above — this
335
+ * cancels by event/context name, not by subscription id, so it can affect a
336
+ * subscription this same connection made elsewhere. New code should call
337
+ * the returned handle's `unsubscribe()` from {@link addCallback} instead.
277
338
  * @param events
278
339
  * @param browsingContexts
279
340
  * @returns {Promise<void>}
@@ -311,6 +372,117 @@ class Index extends EventEmitter {
311
372
  await this.send(params)
312
373
  }
313
374
 
375
+ /**
376
+ * Registers `handler` to be called on every delivered `method` event,
377
+ * globally (no context/user-context scoping). This is the correct mechanism
378
+ * — see the note on {@link subscribe}/{@link unsubscribe} above, which is a
379
+ * separate, imprecise, event-name-scoped mechanism kept only for the
380
+ * existing hand-written bidi/*.js modules until they're replaced by
381
+ * generated code built on this method instead.
382
+ *
383
+ * Keyed by the server-assigned `subscription` id from `session.subscribe`'s
384
+ * response, which the spec mints fresh on every call — so multiple
385
+ * independent subscriptions to the same event coexist safely, and the
386
+ * returned handle's `unsubscribe()` never affects another callback
387
+ * registered for the same method. No client-side ref-counting is needed:
388
+ * the protocol's own per-subscription id already gives each caller its own
389
+ * independent, individually-cancellable registration.
390
+ *
391
+ * The local listener is attached before `session.subscribe` is awaited, not
392
+ * after — so an event the browser starts sending as soon as it processes
393
+ * the subscription can't arrive in a gap where nothing is listening yet.
394
+ * If the subscribe call then fails (or returns no usable id), the listener
395
+ * is removed again before the error propagates, so a failed subscription
396
+ * doesn't leak one.
397
+ * @param {string} method
398
+ * @param {function(unknown): void} handler
399
+ * @returns {Promise<{id: string, unsubscribe: function(): Promise<void>}>}
400
+ */
401
+ async addCallback(method, handler) {
402
+ this.on(method, handler)
403
+
404
+ try {
405
+ const response = await this.send({
406
+ method: 'session.subscribe',
407
+ params: { events: [method] },
408
+ })
409
+ // send() resolves with the raw reply on any response, including a
410
+ // wire-level error — check for one explicitly and surface it plainly,
411
+ // rather than letting it fall through to the generic "no subscription
412
+ // id" message below (matching how Domain#send() reports the same shape).
413
+ if (response?.error !== undefined) {
414
+ throw new Error(`${response.error}: ${response.message}`)
415
+ }
416
+ const subscriptionId = response?.result?.subscription
417
+ if (typeof subscriptionId !== 'string' || subscriptionId === '') {
418
+ throw new Error(`session.subscribe did not return a valid subscription id: ${JSON.stringify(response)}`)
419
+ }
420
+
421
+ this._callbacks.set(subscriptionId, { method, handler })
422
+
423
+ return {
424
+ id: subscriptionId,
425
+ unsubscribe: () => this.removeCallback(subscriptionId),
426
+ }
427
+ } catch (err) {
428
+ this.off(method, handler)
429
+ throw err
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Removes exactly the callback registered under `subscriptionId` (as
435
+ * returned by {@link addCallback}). A no-op if already removed — including
436
+ * once the connection is closed, since _failPending() has already cleaned
437
+ * up local state at that point (and there is no remote end left to reach:
438
+ * entry is only ever defined here while _closed is still false, since
439
+ * _failPending() clears every entry in the same synchronous call that sets
440
+ * _closed). Never affects any other callback, including another one
441
+ * registered for the same method.
442
+ *
443
+ * Local state is only cleaned up once the remote end has confirmed the
444
+ * subscription is actually gone — not before sending session.unsubscribe,
445
+ * and not on a wire-level error response. Cleaning up first would leave a
446
+ * phantom "removed" subscription if the send failed or was rejected: local
447
+ * delivery would stop while the browser kept sending it, and a retry would
448
+ * silently no-op since this method's own early return above would find no
449
+ * entry left to act on.
450
+ *
451
+ * Concurrent calls for the same subscriptionId share one in-flight removal
452
+ * instead of each sending their own session.unsubscribe — a second, racing
453
+ * call would otherwise find the subscription already gone (removed by the
454
+ * first) and get a wire-level error for what was a perfectly valid call.
455
+ * The in-flight marker is cleared once the attempt settles, either way, so
456
+ * a later retry after a failure starts a fresh attempt rather than reusing
457
+ * a rejected one.
458
+ * @param {string} subscriptionId
459
+ * @returns {Promise<void>}
460
+ */
461
+ async removeCallback(subscriptionId) {
462
+ const entry = this._callbacks.get(subscriptionId)
463
+ if (entry === undefined) {
464
+ return
465
+ }
466
+
467
+ if (entry.removing === undefined) {
468
+ entry.removing = (async () => {
469
+ const response = await this.send({
470
+ method: 'session.unsubscribe',
471
+ params: { subscriptions: [subscriptionId] },
472
+ })
473
+ if (response?.error !== undefined) {
474
+ throw new Error(`${response.error}: ${response.message}`)
475
+ }
476
+ this._callbacks.delete(subscriptionId)
477
+ this.off(entry.method, entry.handler)
478
+ })().finally(() => {
479
+ entry.removing = undefined
480
+ })
481
+ }
482
+
483
+ return entry.removing
484
+ }
485
+
314
486
  /**
315
487
  * Close ws connection.
316
488
  * @returns {Promise<unknown>}
@@ -0,0 +1,35 @@
1
+ // Licensed to the Software Freedom Conservancy (SFC) under one
2
+ // or more contributor license agreements. See the NOTICE file
3
+ // distributed with this work for additional information
4
+ // regarding copyright ownership. The SFC licenses this file
5
+ // to you under the Apache License, Version 2.0 (the
6
+ // "License"); you may not use this file except in compliance
7
+ // with the License. You may obtain a copy of the License at
8
+ //
9
+ // http://www.apache.org/licenses/LICENSE-2.0
10
+ //
11
+ // Unless required by applicable law or agreed to in writing,
12
+ // software distributed under the License is distributed on an
13
+ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ // KIND, either express or implied. See the License for the
15
+ // specific language governing permissions and limitations
16
+ // under the License.
17
+
18
+ const { register } = require('./registry')
19
+
20
+ /**
21
+ * Registers a schema `enum` — a closed set of string values a field may hold.
22
+ * @param {string} name Schema type name, e.g. 'network.InterceptPhase'.
23
+ * @param {string[]} values The enum's valid values.
24
+ * @returns {{kind: 'enum', values: string[], includes: function(unknown): boolean}}
25
+ * The registered entry, used by validateValue() to check a ref'd value's
26
+ * membership; also returned so a generator can build a discoverable constant from it.
27
+ */
28
+ function defineEnum(name, values) {
29
+ const allowed = new Set(values)
30
+ const entry = { kind: 'enum', values, includes: (value) => allowed.has(value) }
31
+ register(name, entry)
32
+ return entry
33
+ }
34
+
35
+ module.exports = { defineEnum }