UNPKG

selenium-webdriver

Version:

The official WebDriver JavaScript bindings from the Selenium project

518 lines (475 loc) 19.1 kB
// 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