From e470c82237ae045b0b5b3671aebbcaf861ccffdc Mon Sep 17 00:00:00 2001 From: devlikeapro <155617407+devlikepro@users.noreply.github.com> Date: Wed, 25 Sep 2024 22:31:35 +0700 Subject: [PATCH] Use idempotent exposeFunctionIfAbsent (no reinject flag) (#3233) * Add exposeFunctionIfAbsent function * use exposeFunctionIfAbsent function instead of original exposeFunction * Remove "reinject" flag * exposeFunctionIfAbsent - onQRChangedEvent * exposeFunctionIfAbsent - onLogoutEvent --- src/Client.js | 439 ++++++++++++++++++++---------------------- src/util/Puppeteer.js | 23 +++ 2 files changed, 235 insertions(+), 227 deletions(-) create mode 100644 src/util/Puppeteer.js diff --git a/src/Client.js b/src/Client.js index e9f5d12..f040362 100644 --- a/src/Client.js +++ b/src/Client.js @@ -17,6 +17,7 @@ const ContactFactory = require('./factories/ContactFactory'); const WebCacheFactory = require('./webCache/WebCacheFactory'); const { ClientInfo, Message, MessageMedia, Contact, Location, Poll, PollVote, GroupNotification, Label, Call, Buttons, List, Reaction } = require('./structures'); const NoAuth = require('./authStrategies/NoAuth'); +const {exposeFunctionIfAbsent} = require('./util/Puppeteer'); /** * Starting point for interacting with the WhatsApp Web API @@ -90,9 +91,8 @@ class Client extends EventEmitter { /** * Injection logic * Private function - * @property {boolean} reinject is this a reinject? */ - async inject(reinject = false) { + async inject() { await this.pupPage.waitForFunction('window.Debug?.VERSION != undefined', {timeout: this.options.authTimeoutMs}); const version = await this.getWWebVersion(); @@ -142,26 +142,21 @@ class Client extends EventEmitter { // Register qr events let qrRetries = 0; - const injected = await this.pupPage.evaluate(() => { - return typeof window.onQRChangedEvent !== 'undefined'; - }); - if (!injected) { - await this.pupPage.exposeFunction('onQRChangedEvent', async (qr) => { - /** - * Emitted when a QR code is received - * @event Client#qr - * @param {string} qr QR Code - */ - this.emit(Events.QR_RECEIVED, qr); - if (this.options.qrMaxRetries > 0) { - qrRetries++; - if (qrRetries > this.options.qrMaxRetries) { - this.emit(Events.DISCONNECTED, 'Max qrcode retries reached'); - await this.destroy(); - } + await exposeFunctionIfAbsent(this.pupPage, 'onQRChangedEvent', async (qr) => { + /** + * Emitted when a QR code is received + * @event Client#qr + * @param {string} qr QR Code + */ + this.emit(Events.QR_RECEIVED, qr); + if (this.options.qrMaxRetries > 0) { + qrRetries++; + if (qrRetries > this.options.qrMaxRetries) { + this.emit(Events.DISCONNECTED, 'Max qrcode retries reached'); + await this.destroy(); } - }); - } + } + }); await this.pupPage.evaluate(async () => { @@ -178,86 +173,78 @@ class Client extends EventEmitter { }); } - if (!reinject) { - await this.pupPage.exposeFunction('onAuthAppStateChangedEvent', async (state) => { - if (state == 'UNPAIRED_IDLE') { - // refresh qr code - window.Store.Cmd.refreshQR(); - } - }); + await exposeFunctionIfAbsent(this.pupPage, 'onAuthAppStateChangedEvent', async (state) => { + if (state == 'UNPAIRED_IDLE') { + // refresh qr code + window.Store.Cmd.refreshQR(); + } + }); - await this.pupPage.exposeFunction('onAppStateHasSyncedEvent', async () => { - const authEventPayload = await this.authStrategy.getAuthEventPayload(); - /** + await exposeFunctionIfAbsent(this.pupPage, 'onAppStateHasSyncedEvent', async () => { + const authEventPayload = await this.authStrategy.getAuthEventPayload(); + /** * Emitted when authentication is successful * @event Client#authenticated */ - this.emit(Events.AUTHENTICATED, authEventPayload); + this.emit(Events.AUTHENTICATED, authEventPayload); - const injected = await this.pupPage.evaluate(async () => { - return typeof window.Store !== 'undefined' && typeof window.WWebJS !== 'undefined'; - }); + const injected = await this.pupPage.evaluate(async () => { + return typeof window.Store !== 'undefined' && typeof window.WWebJS !== 'undefined'; + }); - if (!injected) { - if (this.options.webVersionCache.type === 'local' && this.currentIndexHtml) { - const { type: webCacheType, ...webCacheOptions } = this.options.webVersionCache; - const webCache = WebCacheFactory.createWebCache(webCacheType, webCacheOptions); + if (!injected) { + if (this.options.webVersionCache.type === 'local' && this.currentIndexHtml) { + const { type: webCacheType, ...webCacheOptions } = this.options.webVersionCache; + const webCache = WebCacheFactory.createWebCache(webCacheType, webCacheOptions); - await webCache.persist(this.currentIndexHtml, version); - } + await webCache.persist(this.currentIndexHtml, version); + } - if (isCometOrAbove) { - await this.pupPage.evaluate(ExposeStore); - } else { - // make sure all modules are ready before injection - // 2 second delay after authentication makes sense and does not need to be made dyanmic or removed - await new Promise(r => setTimeout(r, 2000)); - await this.pupPage.evaluate(ExposeLegacyStore); - } + if (isCometOrAbove) { + await this.pupPage.evaluate(ExposeStore); + } else { + // make sure all modules are ready before injection + // 2 second delay after authentication makes sense and does not need to be made dyanmic or removed + await new Promise(r => setTimeout(r, 2000)); + await this.pupPage.evaluate(ExposeLegacyStore); + } - // Check window.Store Injection - await this.pupPage.waitForFunction('window.Store != undefined'); + // Check window.Store Injection + await this.pupPage.waitForFunction('window.Store != undefined'); - /** + /** * Current connection information * @type {ClientInfo} */ - this.info = new ClientInfo(this, await this.pupPage.evaluate(() => { - return { ...window.Store.Conn.serialize(), wid: window.Store.User.getMeUser() }; - })); + this.info = new ClientInfo(this, await this.pupPage.evaluate(() => { + return { ...window.Store.Conn.serialize(), wid: window.Store.User.getMeUser() }; + })); - this.interface = new InterfaceController(this); + this.interface = new InterfaceController(this); - //Load util functions (serializers, helper functions) - await this.pupPage.evaluate(LoadUtils); + //Load util functions (serializers, helper functions) + await this.pupPage.evaluate(LoadUtils); - await this.attachEventListeners(reinject); - reinject = true; - } - /** + await this.attachEventListeners(); + } + /** * Emitted when the client has initialized and is ready to receive messages. * @event Client#ready */ - this.emit(Events.READY); - this.authStrategy.afterAuthReady(); - }); - let lastPercent = null; - await this.pupPage.exposeFunction('onOfflineProgressUpdateEvent', async (percent) => { - if (lastPercent !== percent) { - lastPercent = percent; - this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now - } - }); - } - const logoutCatchInjected = await this.pupPage.evaluate(() => { - return typeof window.onLogoutEvent !== 'undefined'; + this.emit(Events.READY); + this.authStrategy.afterAuthReady(); + }); + let lastPercent = null; + await exposeFunctionIfAbsent(this.pupPage, 'onOfflineProgressUpdateEvent', async (percent) => { + if (lastPercent !== percent) { + lastPercent = percent; + this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now + } + }); + await exposeFunctionIfAbsent(this.pupPage, 'onLogoutEvent', async () => { + this.lastLoggedOut = true; + await this.pupPage.waitForNavigation({waitUntil: 'load', timeout: 5000}).catch((_) => _); }); - if (!logoutCatchInjected) { - await this.pupPage.exposeFunction('onLogoutEvent', async () => { - this.lastLoggedOut = true; - await this.pupPage.waitForNavigation({waitUntil: 'load', timeout: 5000}).catch((_) => _); - }); - } await this.pupPage.evaluate(() => { window.AuthStore.AppState.on('change:state', (_AppState, state) => { window.onAuthAppStateChangedEvent(state); }); window.AuthStore.AppState.on('change:hasSynced', () => { window.onAppStateHasSyncedEvent(); }); @@ -349,7 +336,7 @@ class Client extends EventEmitter { await this.authStrategy.afterBrowserInitialized(); this.lastLoggedOut = false; } - await this.inject(true); + await this.inject(); }); } @@ -372,34 +359,33 @@ class Client extends EventEmitter { * Private function * @property {boolean} reinject is this a reinject? */ - async attachEventListeners(reinject = false) { - if (!reinject) { - await this.pupPage.exposeFunction('onAddMessageEvent', msg => { - if (msg.type === 'gp2') { - const notification = new GroupNotification(this, msg); - if (['add', 'invite', 'linked_group_join'].includes(msg.subtype)) { - /** + async attachEventListeners() { + await exposeFunctionIfAbsent(this.pupPage, 'onAddMessageEvent', msg => { + if (msg.type === 'gp2') { + const notification = new GroupNotification(this, msg); + if (['add', 'invite', 'linked_group_join'].includes(msg.subtype)) { + /** * Emitted when a user joins the chat via invite link or is added by an admin. * @event Client#group_join * @param {GroupNotification} notification GroupNotification with more information about the action */ - this.emit(Events.GROUP_JOIN, notification); - } else if (msg.subtype === 'remove' || msg.subtype === 'leave') { - /** + this.emit(Events.GROUP_JOIN, notification); + } else if (msg.subtype === 'remove' || msg.subtype === 'leave') { + /** * Emitted when a user leaves the chat or is removed by an admin. * @event Client#group_leave * @param {GroupNotification} notification GroupNotification with more information about the action */ - this.emit(Events.GROUP_LEAVE, notification); - } else if (msg.subtype === 'promote' || msg.subtype === 'demote') { - /** + this.emit(Events.GROUP_LEAVE, notification); + } else if (msg.subtype === 'promote' || msg.subtype === 'demote') { + /** * Emitted when a current user is promoted to an admin or demoted to a regular user. * @event Client#group_admin_changed * @param {GroupNotification} notification GroupNotification with more information about the action */ - this.emit(Events.GROUP_ADMIN_CHANGED, notification); - } else if (msg.subtype === 'membership_approval_request') { - /** + this.emit(Events.GROUP_ADMIN_CHANGED, notification); + } else if (msg.subtype === 'membership_approval_request') { + /** * Emitted when some user requested to join the group * that has the membership approval mode turned on * @event Client#group_membership_request @@ -408,86 +394,86 @@ class Client extends EventEmitter { * @param {string} notification.author The user ID that made a request * @param {number} notification.timestamp The timestamp the request was made at */ - this.emit(Events.GROUP_MEMBERSHIP_REQUEST, notification); - } else { - /** + this.emit(Events.GROUP_MEMBERSHIP_REQUEST, notification); + } else { + /** * Emitted when group settings are updated, such as subject, description or picture. * @event Client#group_update * @param {GroupNotification} notification GroupNotification with more information about the action */ - this.emit(Events.GROUP_UPDATE, notification); - } - return; + this.emit(Events.GROUP_UPDATE, notification); } + return; + } - const message = new Message(this, msg); + const message = new Message(this, msg); - /** + /** * Emitted when a new message is created, which may include the current user's own messages. * @event Client#message_create * @param {Message} message The message that was created */ - this.emit(Events.MESSAGE_CREATE, message); + this.emit(Events.MESSAGE_CREATE, message); - if (msg.id.fromMe) return; + if (msg.id.fromMe) return; - /** + /** * Emitted when a new message is received. * @event Client#message * @param {Message} message The message that was received */ - this.emit(Events.MESSAGE_RECEIVED, message); - }); + this.emit(Events.MESSAGE_RECEIVED, message); + }); - let last_message; + let last_message; - await this.pupPage.exposeFunction('onChangeMessageTypeEvent', (msg) => { + await exposeFunctionIfAbsent(this.pupPage, 'onChangeMessageTypeEvent', (msg) => { - if (msg.type === 'revoked') { - const message = new Message(this, msg); - let revoked_msg; - if (last_message && msg.id.id === last_message.id.id) { - revoked_msg = new Message(this, last_message); - } + if (msg.type === 'revoked') { + const message = new Message(this, msg); + let revoked_msg; + if (last_message && msg.id.id === last_message.id.id) { + revoked_msg = new Message(this, last_message); + } - /** + /** * Emitted when a message is deleted for everyone in the chat. * @event Client#message_revoke_everyone * @param {Message} message The message that was revoked, in its current state. It will not contain the original message's data. * @param {?Message} revoked_msg The message that was revoked, before it was revoked. It will contain the message's original data. * Note that due to the way this data is captured, it may be possible that this param will be undefined. */ - this.emit(Events.MESSAGE_REVOKED_EVERYONE, message, revoked_msg); - } + this.emit(Events.MESSAGE_REVOKED_EVERYONE, message, revoked_msg); + } - }); + }); - await this.pupPage.exposeFunction('onChangeMessageEvent', (msg) => { + await exposeFunctionIfAbsent(this.pupPage, 'onChangeMessageEvent', (msg) => { - if (msg.type !== 'revoked') { - last_message = msg; - } + if (msg.type !== 'revoked') { + last_message = msg; + } - /** + /** * The event notification that is received when one of * the group participants changes their phone number. */ - const isParticipant = msg.type === 'gp2' && msg.subtype === 'modify'; + const isParticipant = msg.type === 'gp2' && msg.subtype === 'modify'; - /** + /** * The event notification that is received when one of * the contacts changes their phone number. */ - const isContact = msg.type === 'notification_template' && msg.subtype === 'change_number'; + const isContact = msg.type === 'notification_template' && msg.subtype === 'change_number'; - if (isParticipant || isContact) { - /** @type {GroupNotification} object does not provide enough information about this event, so a @type {Message} object is used. */ - const message = new Message(this, msg); + if (isParticipant || isContact) { + /** @type {GroupNotification} object does not provide enough information about this event, so a @type {Message} object is used. */ + const message = new Message(this, msg); - const newId = isParticipant ? msg.recipients[0] : msg.to; - const oldId = isParticipant ? msg.author : msg.templateParams.find(id => id !== newId); + const newId = isParticipant ? msg.recipients[0] : msg.to; + const oldId = isParticipant ? msg.author : msg.templateParams.find(id => id !== newId); - /** + /** * Emitted when a contact or a group participant changes their phone number. * @event Client#contact_changed * @param {Message} message Message with more information about the event. @@ -496,98 +482,98 @@ class Client extends EventEmitter { * @param {String} newId The user's new id after the change. * @param {Boolean} isContact Indicates if a contact or a group participant changed their phone number. */ - this.emit(Events.CONTACT_CHANGED, message, oldId, newId, isContact); - } - }); + this.emit(Events.CONTACT_CHANGED, message, oldId, newId, isContact); + } + }); - await this.pupPage.exposeFunction('onRemoveMessageEvent', (msg) => { + await exposeFunctionIfAbsent(this.pupPage, 'onRemoveMessageEvent', (msg) => { - if (!msg.isNewMsg) return; + if (!msg.isNewMsg) return; - const message = new Message(this, msg); + const message = new Message(this, msg); - /** + /** * Emitted when a message is deleted by the current user. * @event Client#message_revoke_me * @param {Message} message The message that was revoked */ - this.emit(Events.MESSAGE_REVOKED_ME, message); + this.emit(Events.MESSAGE_REVOKED_ME, message); - }); + }); - await this.pupPage.exposeFunction('onMessageAckEvent', (msg, ack) => { + await exposeFunctionIfAbsent(this.pupPage, 'onMessageAckEvent', (msg, ack) => { - const message = new Message(this, msg); + const message = new Message(this, msg); - /** + /** * Emitted when an ack event occurrs on message type. * @event Client#message_ack * @param {Message} message The message that was affected * @param {MessageAck} ack The new ACK value */ - this.emit(Events.MESSAGE_ACK, message, ack); + this.emit(Events.MESSAGE_ACK, message, ack); - }); + }); - await this.pupPage.exposeFunction('onChatUnreadCountEvent', async (data) =>{ - const chat = await this.getChatById(data.id); + await exposeFunctionIfAbsent(this.pupPage, 'onChatUnreadCountEvent', async (data) =>{ + const chat = await this.getChatById(data.id); - /** + /** * Emitted when the chat unread count changes */ - this.emit(Events.UNREAD_COUNT, chat); - }); + this.emit(Events.UNREAD_COUNT, chat); + }); - await this.pupPage.exposeFunction('onMessageMediaUploadedEvent', (msg) => { + await exposeFunctionIfAbsent(this.pupPage, 'onMessageMediaUploadedEvent', (msg) => { - const message = new Message(this, msg); + const message = new Message(this, msg); - /** + /** * Emitted when media has been uploaded for a message sent by the client. * @event Client#media_uploaded * @param {Message} message The message with media that was uploaded */ - this.emit(Events.MEDIA_UPLOADED, message); - }); + this.emit(Events.MEDIA_UPLOADED, message); + }); - await this.pupPage.exposeFunction('onAppStateChangedEvent', async (state) => { - /** + await exposeFunctionIfAbsent(this.pupPage, 'onAppStateChangedEvent', async (state) => { + /** * Emitted when the connection state changes * @event Client#change_state * @param {WAState} state the new connection state */ - this.emit(Events.STATE_CHANGED, state); + this.emit(Events.STATE_CHANGED, state); - const ACCEPTED_STATES = [WAState.CONNECTED, WAState.OPENING, WAState.PAIRING, WAState.TIMEOUT]; + const ACCEPTED_STATES = [WAState.CONNECTED, WAState.OPENING, WAState.PAIRING, WAState.TIMEOUT]; - if (this.options.takeoverOnConflict) { - ACCEPTED_STATES.push(WAState.CONFLICT); + if (this.options.takeoverOnConflict) { + ACCEPTED_STATES.push(WAState.CONFLICT); - if (state === WAState.CONFLICT) { - setTimeout(() => { - this.pupPage.evaluate(() => window.Store.AppState.takeover()); - }, this.options.takeoverTimeoutMs); - } + if (state === WAState.CONFLICT) { + setTimeout(() => { + this.pupPage.evaluate(() => window.Store.AppState.takeover()); + }, this.options.takeoverTimeoutMs); } + } - if (!ACCEPTED_STATES.includes(state)) { - /** + if (!ACCEPTED_STATES.includes(state)) { + /** * Emitted when the client has been disconnected * @event Client#disconnected * @param {WAState|"LOGOUT"} reason reason that caused the disconnect */ - await this.authStrategy.disconnect(); - this.emit(Events.DISCONNECTED, state); - this.destroy(); - } - }); + await this.authStrategy.disconnect(); + this.emit(Events.DISCONNECTED, state); + this.destroy(); + } + }); - await this.pupPage.exposeFunction('onBatteryStateChangedEvent', (state) => { - const { battery, plugged } = state; + await exposeFunctionIfAbsent(this.pupPage, 'onBatteryStateChangedEvent', (state) => { + const { battery, plugged } = state; - if (battery === undefined) return; + if (battery === undefined) return; - /** + /** * Emitted when the battery percentage for the attached device changes. Will not be sent if using multi-device. * @event Client#change_battery * @param {object} batteryInfo @@ -595,11 +581,11 @@ class Client extends EventEmitter { * @param {boolean} batteryInfo.plugged - Indicates if the phone is plugged in (true) or not (false) * @deprecated */ - this.emit(Events.BATTERY_CHANGED, { battery, plugged }); - }); + this.emit(Events.BATTERY_CHANGED, { battery, plugged }); + }); - await this.pupPage.exposeFunction('onIncomingCall', (call) => { - /** + await exposeFunctionIfAbsent(this.pupPage, 'onIncomingCall', (call) => { + /** * Emitted when a call is received * @event Client#incoming_call * @param {object} call @@ -612,13 +598,13 @@ class Client extends EventEmitter { * @param {boolean} call.webClientShouldHandle - If Waweb should handle * @param {object} call.participants - Participants */ - const cll = new Call(this, call); - this.emit(Events.INCOMING_CALL, cll); - }); + const cll = new Call(this, call); + this.emit(Events.INCOMING_CALL, cll); + }); - await this.pupPage.exposeFunction('onReaction', (reactions) => { - for (const reaction of reactions) { - /** + await exposeFunctionIfAbsent(this.pupPage, 'onReaction', (reactions) => { + for (const reaction of reactions) { + /** * Emitted when a reaction is sent, received, updated or removed * @event Client#message_reaction * @param {object} reaction @@ -633,69 +619,68 @@ class Client extends EventEmitter { * @param {?number} reaction.ack - Ack */ - this.emit(Events.MESSAGE_REACTION, new Reaction(this, reaction)); - } - }); + this.emit(Events.MESSAGE_REACTION, new Reaction(this, reaction)); + } + }); - await this.pupPage.exposeFunction('onRemoveChatEvent', async (chat) => { - const _chat = await this.getChatById(chat.id); + await exposeFunctionIfAbsent(this.pupPage, 'onRemoveChatEvent', async (chat) => { + const _chat = await this.getChatById(chat.id); - /** + /** * Emitted when a chat is removed * @event Client#chat_removed * @param {Chat} chat */ - this.emit(Events.CHAT_REMOVED, _chat); - }); + this.emit(Events.CHAT_REMOVED, _chat); + }); - await this.pupPage.exposeFunction('onArchiveChatEvent', async (chat, currState, prevState) => { - const _chat = await this.getChatById(chat.id); + await exposeFunctionIfAbsent(this.pupPage, 'onArchiveChatEvent', async (chat, currState, prevState) => { + const _chat = await this.getChatById(chat.id); - /** + /** * Emitted when a chat is archived/unarchived * @event Client#chat_archived * @param {Chat} chat * @param {boolean} currState * @param {boolean} prevState */ - this.emit(Events.CHAT_ARCHIVED, _chat, currState, prevState); - }); + this.emit(Events.CHAT_ARCHIVED, _chat, currState, prevState); + }); - await this.pupPage.exposeFunction('onEditMessageEvent', (msg, newBody, prevBody) => { + await exposeFunctionIfAbsent(this.pupPage, 'onEditMessageEvent', (msg, newBody, prevBody) => { - if(msg.type === 'revoked'){ - return; - } - /** + if(msg.type === 'revoked'){ + return; + } + /** * Emitted when messages are edited * @event Client#message_edit * @param {Message} message * @param {string} newBody * @param {string} prevBody */ - this.emit(Events.MESSAGE_EDIT, new Message(this, msg), newBody, prevBody); - }); + this.emit(Events.MESSAGE_EDIT, new Message(this, msg), newBody, prevBody); + }); - await this.pupPage.exposeFunction('onAddMessageCiphertextEvent', msg => { + await exposeFunctionIfAbsent(this.pupPage, 'onAddMessageCiphertextEvent', msg => { - /** + /** * Emitted when messages are edited * @event Client#message_ciphertext * @param {Message} message */ - this.emit(Events.MESSAGE_CIPHERTEXT, new Message(this, msg)); - }); + this.emit(Events.MESSAGE_CIPHERTEXT, new Message(this, msg)); + }); - await this.pupPage.exposeFunction('onPollVoteEvent', (vote) => { - const _vote = new PollVote(this, vote); - /** - * Emitted when some poll option is selected or deselected, - * shows a user's current selected option(s) on the poll - * @event Client#vote_update - */ - this.emit(Events.VOTE_UPDATE, _vote); - }); - } + await exposeFunctionIfAbsent(this.pupPage, 'onPollVoteEvent', (vote) => { + const _vote = new PollVote(this, vote); + /** + * Emitted when some poll option is selected or deselected, + * shows a user's current selected option(s) on the poll + * @event Client#vote_update + */ + this.emit(Events.VOTE_UPDATE, _vote); + }); await this.pupPage.evaluate(() => { window.Store.Msg.on('change', (msg) => { window.onChangeMessageEvent(window.WWebJS.getMessageModel(msg)); }); diff --git a/src/util/Puppeteer.js b/src/util/Puppeteer.js new file mode 100644 index 0000000..f6f2e43 --- /dev/null +++ b/src/util/Puppeteer.js @@ -0,0 +1,23 @@ +/** + * Expose a function to the page if it does not exist + * + * NOTE: + * Rewrite it to 'upsertFunction' after updating Puppeteer to 20.6 or higher + * using page.removeExposedFunction + * https://pptr.dev/api/puppeteer.page.removeExposedFunction + * + * @param {import(puppeteer).Page} page + * @param {string} name + * @param {Function} fn + */ +async function exposeFunctionIfAbsent(page, name, fn) { + const exist = await page.evaluate((name) => { + return !!window[name]; + }, name); + if (exist) { + return; + } + await page.exposeFunction(name, fn); +} + +module.exports = {exposeFunctionIfAbsent};