selenium-webdriver
Version:
The official WebDriver JavaScript bindings from the Selenium project
518 lines (475 loc) • 19.1 kB
JavaScript
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
const { EventEmitter } = require('node:events')
const WebSocket = require('ws')
const RESPONSE_TIMEOUT = 1000 * 30
class Index extends EventEmitter {
id = 0
connected = false
events = []
browsingContexts = []
/**
* Create a new websocket connection
* @param _webSocketUrl
*/
constructor(_webSocketUrl) {
super()
this.connected = false
this._closed = false
this._pending = new Map()
this._connectWaiters = new Set()
// removeCallback(id) only receives the subscriptionId — off() needs the event
// name and the exact handler function too, so this holds what it needs to
// detach the right listener without the caller having to keep them around.
this._callbacks = new Map()
this._ws = new WebSocket(_webSocketUrl)
this._ws.on('open', () => {
// The handshake can complete after close()/_failPending() has already
// marked the connection closed. Don't flip connected back to true and
// proactively close the now-orphan socket so it does not leak.
if (this._closed) {
try {
this._ws.close()
} catch {
/* socket already closing */
}
return
}
this.connected = true
for (const { resolve } of this._connectWaiters) {
resolve()
}
this._connectWaiters.clear()
})
// Single shared response dispatcher. Avoids attaching a new 'message'
// listener for every in-flight send(), which previously caused
// MaxListenersExceededWarning under concurrent BiDi traffic
// (e.g. network interception during a page navigation).
this._ws.on('message', (data) => {
// Frames can arrive after close() has cleared _pending; ignore them
// rather than re-emitting parse errors or dispatching to nothing.
if (this._closed) {
return
}
let payload
try {
payload = JSON.parse(data.toString())
} catch (err) {
// Surface protocol parse failures rather than silently dropping —
// otherwise callers see misleading send() timeouts.
this._emitOrWarn(new Error(`Failed to parse BiDi message: ${err.message}`), 'BiDiProtocolWarning')
return
}
// Messages without a numeric id are BiDi events, not command responses.
// Re-emit them on this EventEmitter by method name (e.g.
// 'browsingContext.contextCreated') so that generated domain classes can
// subscribe via bidi.on(methodName, callback) instead of each attaching
// a new raw ws.on('message', ...) listener. The existing hand-written
// modules (logInspector, network, etc.) continue to use their own
// ws.on('message', ...) listeners unchanged — this emission is purely
// additive and does not affect those code paths.
if (payload == null || typeof payload.id !== 'number') {
if (payload != null && typeof payload.method === 'string') {
// 'error' is a reserved EventEmitter event — emitting it without a
// listener throws and crashes the process. Route any peer-supplied
// method named 'error' through the same guarded path used for JSON
// parse failures rather than forwarding it directly.
if (payload.method === 'error') {
this._emitOrWarn(
new Error(`BiDi protocol error event: ${JSON.stringify(payload.params)}`),
'BiDiProtocolWarning',
)
} else {
// A listener can throw synchronously — most notably a typed
// addCallback() dispatcher's fromWire() rejecting a corrupted
// payload, which is meant to error rather than warn. Dispatched
// one listener at a time (not via a single this.emit() call) so a
// throwing listener doesn't prevent a sibling listener registered
// for the same event from still receiving this delivery — emit()
// itself aborts the rest of its iteration once one listener throws.
// rawListeners(), not listeners(): listeners() unwraps a once()
// registration to the caller's original function, so invoking it
// here directly (bypassing emit()) would skip the internal wrapper
// that removes it after one call — rawListeners() returns that
// wrapper itself, preserving once()'s self-removal.
for (const listener of this.rawListeners(payload.method)) {
try {
listener(payload.params)
} catch (err) {
const wrapped = err instanceof Error ? err : new Error(String(err))
this._emitOrWarn(wrapped, 'BiDiEventHandlerWarning')
}
}
}
}
return
}
const entry = this._pending.get(payload.id)
if (entry === undefined) {
return
}
clearTimeout(entry.timeoutId)
this._pending.delete(payload.id)
entry.resolve(payload)
})
// Fail any in-flight send() calls promptly when the peer disconnects
// or the socket errors, instead of waiting for RESPONSE_TIMEOUT.
this._ws.on('close', () => {
this._failPending(new Error('BiDi connection closed unexpectedly'))
})
this._ws.on('error', (err) => {
this._failPending(new Error(`BiDi connection error: ${err.message}`))
})
}
/**
* Reject any in-flight sends and mark the connection failed. Idempotent so
* that close() and the underlying 'close'/'error' events do not double-reject.
* @param {Error} error
* @private
*/
_failPending(error) {
if (this._closed) {
return
}
this._closed = true
this.connected = false
for (const { reject, timeoutId } of this._pending.values()) {
clearTimeout(timeoutId)
reject(error)
}
this._pending.clear()
// Reject any callers parked in waitForConnection() so close() (or an
// unexpected disconnect) cannot leave them hanging forever.
for (const { reject } of this._connectWaiters) {
reject(error)
}
this._connectWaiters.clear()
// Detach every addCallback() listener too. Once closed, removeCallback()
// can no longer reach the remote end (send() would just throw), so nothing
// else will ever detach these listeners. Drop them here instead of leaving
// them attached to an EventEmitter nothing will ever emit on again.
for (const { method, handler } of this._callbacks.values()) {
this.off(method, handler)
}
this._callbacks.clear()
}
/**
* Emits `err` as an 'error' event if anything is listening for one,
* otherwise reports it as a process warning under `warningType` — never
* emits 'error' with no listener attached, which would itself throw and
* crash the process. Also guards against the 'error' listener itself
* throwing, so a broken listener can't cause the exact kind of crash this
* helper exists to prevent, just one level removed.
* @param {Error} err
* @param {string} warningType
* @private
*/
_emitOrWarn(err, warningType) {
if (this.listenerCount('error') === 0) {
process.emitWarning(err.message, warningType)
return
}
try {
this.emit('error', err)
} catch (listenerErr) {
const wrapped = listenerErr instanceof Error ? listenerErr : new Error(String(listenerErr))
process.emitWarning(`BiDi 'error' listener threw: ${wrapped.message}`, warningType)
}
}
/**
* @returns {WebSocket}
*/
get socket() {
return this._ws
}
/**
* @returns {boolean|*}
*/
get isConnected() {
return this.connected
}
/**
* Get Bidi Status
* @returns {Promise<*>}
*/
get status() {
return this.send({
method: 'session.status',
params: {},
})
}
/**
* Resolve connection
* @returns {Promise<unknown>}
*/
async waitForConnection() {
return new Promise((resolve, reject) => {
if (this._closed) {
reject(new Error('BiDi connection is closed'))
return
}
if (this.connected) {
resolve()
return
}
// Park the waiter in a Set so the constructor's 'open' handler can
// resolve it and _failPending() can reject it. Avoids attaching socket
// listeners that close()'s removeAllListeners('close') would strip.
this._connectWaiters.add({ resolve, reject })
})
}
/**
* Sends a bidi request
* @param params
* @returns {Promise<unknown>}
*/
async send(params) {
if (this._closed) {
throw new Error('BiDi connection is closed')
}
if (!this.connected) {
await this.waitForConnection()
}
// Defense in depth: even after waitForConnection() resolves, the socket
// may have transitioned to CLOSING/CLOSED (e.g. caller closed the raw
// socket). Refuse rather than throwing from inside ws.send().
if (this._ws.readyState !== WebSocket.OPEN) {
throw new Error('BiDi connection is not open')
}
const id = ++this.id
this._ws.send(JSON.stringify({ id, ...params }))
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this._pending.delete(id)
reject(new Error(`Request with id ${id} timed out`))
}, RESPONSE_TIMEOUT)
this._pending.set(id, { resolve, reject, timeoutId })
})
}
/**
* Subscribe to events.
*
* Not the correct implementation — this is not tied to a subscription id, so
* {@link unsubscribe} below cancels by event/context name and can affect a
* subscription made elsewhere (including via {@link addCallback}) for the
* same event. Kept as-is only because the existing hand-written bidi/*.js
* modules already depend on this exact shape; new code should use
* {@link addCallback} instead, which is properly scoped by subscription id
* (mirroring Java's BiDi#addListener/removeListener — see BiDi.java). Once
* those hand-written modules are replaced by generated code built on
* addCallback/removeCallback, this method (and unsubscribe) can be removed.
* @param events
* @param browsingContexts
* @returns {Promise<void>}
*/
async subscribe(events, browsingContexts) {
function toArray(arg) {
if (arg === undefined) {
return []
}
return Array.isArray(arg) ? [...arg] : [arg]
}
const eventsArray = toArray(events)
const contextsArray = toArray(browsingContexts)
const params = {
method: 'session.subscribe',
params: {},
}
if (eventsArray.length && eventsArray.some((event) => typeof event !== 'string')) {
throw new TypeError('events should be string or string array')
}
if (contextsArray.length && contextsArray.some((context) => typeof context !== 'string')) {
throw new TypeError('browsingContexts should be string or string array')
}
if (eventsArray.length) {
params.params.events = eventsArray
}
if (contextsArray.length) {
params.params.contexts = contextsArray
}
this.events.push(...eventsArray)
await this.send(params)
}
/**
* Unsubscribe to events. See the note on {@link subscribe} above — this
* cancels by event/context name, not by subscription id, so it can affect a
* subscription this same connection made elsewhere. New code should call
* the returned handle's `unsubscribe()` from {@link addCallback} instead.
* @param events
* @param browsingContexts
* @returns {Promise<void>}
*/
async unsubscribe(events, browsingContexts) {
const eventsToRemove = typeof events === 'string' ? [events] : events
// Check if the eventsToRemove are in the subscribed events array
// Filter out events that are not in this.events before filtering
const existingEvents = eventsToRemove.filter((event) => this.events.includes(event))
// Remove the events from the subscribed events array
this.events = this.events.filter((event) => !existingEvents.includes(event))
if (typeof browsingContexts === 'string') {
this.browsingContexts.pop()
} else if (Array.isArray(browsingContexts)) {
this.browsingContexts = this.browsingContexts.filter((id) => !browsingContexts.includes(id))
}
if (existingEvents.length === 0) {
return
}
const params = {
method: 'session.unsubscribe',
params: {
events: existingEvents,
},
}
if (this.browsingContexts.length > 0) {
params.params.contexts = this.browsingContexts
}
await this.send(params)
}
/**
* Registers `handler` to be called on every delivered `method` event,
* globally (no context/user-context scoping). This is the correct mechanism
* — see the note on {@link subscribe}/{@link unsubscribe} above, which is a
* separate, imprecise, event-name-scoped mechanism kept only for the
* existing hand-written bidi/*.js modules until they're replaced by
* generated code built on this method instead.
*
* Keyed by the server-assigned `subscription` id from `session.subscribe`'s
* response, which the spec mints fresh on every call — so multiple
* independent subscriptions to the same event coexist safely, and the
* returned handle's `unsubscribe()` never affects another callback
* registered for the same method. No client-side ref-counting is needed:
* the protocol's own per-subscription id already gives each caller its own
* independent, individually-cancellable registration.
*
* The local listener is attached before `session.subscribe` is awaited, not
* after — so an event the browser starts sending as soon as it processes
* the subscription can't arrive in a gap where nothing is listening yet.
* If the subscribe call then fails (or returns no usable id), the listener
* is removed again before the error propagates, so a failed subscription
* doesn't leak one.
* @param {string} method
* @param {function(unknown): void} handler
* @returns {Promise<{id: string, unsubscribe: function(): Promise<void>}>}
*/
async addCallback(method, handler) {
this.on(method, handler)
try {
const response = await this.send({
method: 'session.subscribe',
params: { events: [method] },
})
// send() resolves with the raw reply on any response, including a
// wire-level error — check for one explicitly and surface it plainly,
// rather than letting it fall through to the generic "no subscription
// id" message below (matching how Domain#send() reports the same shape).
if (response?.error !== undefined) {
throw new Error(`${response.error}: ${response.message}`)
}
const subscriptionId = response?.result?.subscription
if (typeof subscriptionId !== 'string' || subscriptionId === '') {
throw new Error(`session.subscribe did not return a valid subscription id: ${JSON.stringify(response)}`)
}
this._callbacks.set(subscriptionId, { method, handler })
return {
id: subscriptionId,
unsubscribe: () => this.removeCallback(subscriptionId),
}
} catch (err) {
this.off(method, handler)
throw err
}
}
/**
* Removes exactly the callback registered under `subscriptionId` (as
* returned by {@link addCallback}). A no-op if already removed — including
* once the connection is closed, since _failPending() has already cleaned
* up local state at that point (and there is no remote end left to reach:
* entry is only ever defined here while _closed is still false, since
* _failPending() clears every entry in the same synchronous call that sets
* _closed). Never affects any other callback, including another one
* registered for the same method.
*
* Local state is only cleaned up once the remote end has confirmed the
* subscription is actually gone — not before sending session.unsubscribe,
* and not on a wire-level error response. Cleaning up first would leave a
* phantom "removed" subscription if the send failed or was rejected: local
* delivery would stop while the browser kept sending it, and a retry would
* silently no-op since this method's own early return above would find no
* entry left to act on.
*
* Concurrent calls for the same subscriptionId share one in-flight removal
* instead of each sending their own session.unsubscribe — a second, racing
* call would otherwise find the subscription already gone (removed by the
* first) and get a wire-level error for what was a perfectly valid call.
* The in-flight marker is cleared once the attempt settles, either way, so
* a later retry after a failure starts a fresh attempt rather than reusing
* a rejected one.
* @param {string} subscriptionId
* @returns {Promise<void>}
*/
async removeCallback(subscriptionId) {
const entry = this._callbacks.get(subscriptionId)
if (entry === undefined) {
return
}
if (entry.removing === undefined) {
entry.removing = (async () => {
const response = await this.send({
method: 'session.unsubscribe',
params: { subscriptions: [subscriptionId] },
})
if (response?.error !== undefined) {
throw new Error(`${response.error}: ${response.message}`)
}
this._callbacks.delete(subscriptionId)
this.off(entry.method, entry.handler)
})().finally(() => {
entry.removing = undefined
})
}
return entry.removing
}
/**
* Close ws connection.
* @returns {Promise<unknown>}
*/
close() {
this._failPending(new Error('BiDi connection closed before response was received'))
const closeWebSocket = (callback) => {
// don't close if it's already closed
if (this._ws.readyState === 3) {
callback()
} else {
// don't notify on user-initiated shutdown ('disconnect' event)
this._ws.removeAllListeners('close')
this._ws.once('close', () => {
this._ws.removeAllListeners()
callback()
})
this._ws.close()
}
}
return new Promise((fulfill, _) => {
closeWebSocket(fulfill)
})
}
}
/**
* API
* @type {function(*): Promise<Index>}
*/
module.exports = Index