1/** 2 * Copyright 2017 Google Inc. All rights reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16import { TimeoutError } from './Errors.js'; 17import { debug } from './Debug.js'; 18import { CDPSession } from './Connection.js'; 19import { Protocol } from 'devtools-protocol'; 20import { CommonEventEmitter } from './EventEmitter.js'; 21import { assert } from './assert.js'; 22import { isNode } from '../environment.js'; 23 24export const debugError = debug('puppeteer:error'); 25 26function getExceptionMessage( 27 exceptionDetails: Protocol.Runtime.ExceptionDetails 28): string { 29 if (exceptionDetails.exception) 30 return ( 31 exceptionDetails.exception.description || exceptionDetails.exception.value 32 ); 33 let message = exceptionDetails.text; 34 if (exceptionDetails.stackTrace) { 35 for (const callframe of exceptionDetails.stackTrace.callFrames) { 36 const location = 37 callframe.url + 38 ':' + 39 callframe.lineNumber + 40 ':' + 41 callframe.columnNumber; 42 const functionName = callframe.functionName || '<anonymous>'; 43 message += `\n at ${functionName} (${location})`; 44 } 45 } 46 return message; 47} 48 49function valueFromRemoteObject( 50 remoteObject: Protocol.Runtime.RemoteObject 51): any { 52 assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); 53 if (remoteObject.unserializableValue) { 54 if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') 55 return BigInt(remoteObject.unserializableValue.replace('n', '')); 56 switch (remoteObject.unserializableValue) { 57 case '-0': 58 return -0; 59 case 'NaN': 60 return NaN; 61 case 'Infinity': 62 return Infinity; 63 case '-Infinity': 64 return -Infinity; 65 default: 66 throw new Error( 67 'Unsupported unserializable value: ' + 68 remoteObject.unserializableValue 69 ); 70 } 71 } 72 return remoteObject.value; 73} 74 75async function releaseObject( 76 client: CDPSession, 77 remoteObject: Protocol.Runtime.RemoteObject 78): Promise<void> { 79 if (!remoteObject.objectId) return; 80 await client 81 .send('Runtime.releaseObject', { objectId: remoteObject.objectId }) 82 .catch((error) => { 83 // Exceptions might happen in case of a page been navigated or closed. 84 // Swallow these since they are harmless and we don't leak anything in this case. 85 debugError(error); 86 }); 87} 88 89/** 90 * @public 91 */ 92export interface PuppeteerEventListener { 93 emitter: CommonEventEmitter; 94 eventName: string | symbol; 95 handler: (...args: any[]) => void; 96} 97 98function addEventListener( 99 emitter: CommonEventEmitter, 100 eventName: string | symbol, 101 handler: (...args: any[]) => void 102): PuppeteerEventListener { 103 emitter.on(eventName, handler); 104 return { emitter, eventName, handler }; 105} 106 107function removeEventListeners( 108 listeners: Array<{ 109 emitter: CommonEventEmitter; 110 eventName: string | symbol; 111 handler: (...args: any[]) => void; 112 }> 113): void { 114 for (const listener of listeners) 115 listener.emitter.removeListener(listener.eventName, listener.handler); 116 listeners.length = 0; 117} 118 119function isString(obj: unknown): obj is string { 120 return typeof obj === 'string' || obj instanceof String; 121} 122 123function isNumber(obj: unknown): obj is number { 124 return typeof obj === 'number' || obj instanceof Number; 125} 126 127async function waitForEvent<T extends any>( 128 emitter: CommonEventEmitter, 129 eventName: string | symbol, 130 predicate: (event: T) => Promise<boolean> | boolean, 131 timeout: number, 132 abortPromise: Promise<Error> 133): Promise<T> { 134 let eventTimeout, resolveCallback, rejectCallback; 135 const promise = new Promise<T>((resolve, reject) => { 136 resolveCallback = resolve; 137 rejectCallback = reject; 138 }); 139 const listener = addEventListener(emitter, eventName, async (event) => { 140 if (!(await predicate(event))) return; 141 resolveCallback(event); 142 }); 143 if (timeout) { 144 eventTimeout = setTimeout(() => { 145 rejectCallback( 146 new TimeoutError('Timeout exceeded while waiting for event') 147 ); 148 }, timeout); 149 } 150 function cleanup(): void { 151 removeEventListeners([listener]); 152 clearTimeout(eventTimeout); 153 } 154 const result = await Promise.race([promise, abortPromise]).then( 155 (r) => { 156 cleanup(); 157 return r; 158 }, 159 (error) => { 160 cleanup(); 161 throw error; 162 } 163 ); 164 if (result instanceof Error) throw result; 165 166 return result; 167} 168 169function evaluationString(fun: Function | string, ...args: unknown[]): string { 170 if (isString(fun)) { 171 assert(args.length === 0, 'Cannot evaluate a string with arguments'); 172 return fun; 173 } 174 175 function serializeArgument(arg: unknown): string { 176 if (Object.is(arg, undefined)) return 'undefined'; 177 return JSON.stringify(arg); 178 } 179 180 return `(${fun})(${args.map(serializeArgument).join(',')})`; 181} 182 183function pageBindingInitString(type: string, name: string): string { 184 function addPageBinding(type: string, bindingName: string): void { 185 /* Cast window to any here as we're about to add properties to it 186 * via win[bindingName] which TypeScript doesn't like. 187 */ 188 const win = window as any; 189 const binding = win[bindingName]; 190 191 win[bindingName] = (...args: unknown[]): Promise<unknown> => { 192 const me = window[bindingName]; 193 let callbacks = me.callbacks; 194 if (!callbacks) { 195 callbacks = new Map(); 196 me.callbacks = callbacks; 197 } 198 const seq = (me.lastSeq || 0) + 1; 199 me.lastSeq = seq; 200 const promise = new Promise((resolve, reject) => 201 callbacks.set(seq, { resolve, reject }) 202 ); 203 binding(JSON.stringify({ type, name: bindingName, seq, args })); 204 return promise; 205 }; 206 } 207 return evaluationString(addPageBinding, type, name); 208} 209 210function pageBindingDeliverResultString( 211 name: string, 212 seq: number, 213 result: unknown 214): string { 215 function deliverResult(name: string, seq: number, result: unknown): void { 216 window[name].callbacks.get(seq).resolve(result); 217 window[name].callbacks.delete(seq); 218 } 219 return evaluationString(deliverResult, name, seq, result); 220} 221 222function pageBindingDeliverErrorString( 223 name: string, 224 seq: number, 225 message: string, 226 stack: string 227): string { 228 function deliverError( 229 name: string, 230 seq: number, 231 message: string, 232 stack: string 233 ): void { 234 const error = new Error(message); 235 error.stack = stack; 236 window[name].callbacks.get(seq).reject(error); 237 window[name].callbacks.delete(seq); 238 } 239 return evaluationString(deliverError, name, seq, message, stack); 240} 241 242function pageBindingDeliverErrorValueString( 243 name: string, 244 seq: number, 245 value: unknown 246): string { 247 function deliverErrorValue(name: string, seq: number, value: unknown): void { 248 window[name].callbacks.get(seq).reject(value); 249 window[name].callbacks.delete(seq); 250 } 251 return evaluationString(deliverErrorValue, name, seq, value); 252} 253 254function makePredicateString( 255 predicate: Function, 256 predicateQueryHandler?: Function 257): string { 258 function checkWaitForOptions( 259 node: Node, 260 waitForVisible: boolean, 261 waitForHidden: boolean 262 ): Node | null | boolean { 263 if (!node) return waitForHidden; 264 if (!waitForVisible && !waitForHidden) return node; 265 const element = 266 node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element); 267 268 const style = window.getComputedStyle(element); 269 const isVisible = 270 style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); 271 const success = 272 waitForVisible === isVisible || waitForHidden === !isVisible; 273 return success ? node : null; 274 275 function hasVisibleBoundingBox(): boolean { 276 const rect = element.getBoundingClientRect(); 277 return !!(rect.top || rect.bottom || rect.width || rect.height); 278 } 279 } 280 const predicateQueryHandlerDef = predicateQueryHandler 281 ? `const predicateQueryHandler = ${predicateQueryHandler};` 282 : ''; 283 return ` 284 (() => { 285 ${predicateQueryHandlerDef} 286 const checkWaitForOptions = ${checkWaitForOptions}; 287 return (${predicate})(...args) 288 })() `; 289} 290 291async function waitWithTimeout<T extends any>( 292 promise: Promise<T>, 293 taskName: string, 294 timeout: number 295): Promise<T> { 296 let reject; 297 const timeoutError = new TimeoutError( 298 `waiting for ${taskName} failed: timeout ${timeout}ms exceeded` 299 ); 300 const timeoutPromise = new Promise<T>((resolve, x) => (reject = x)); 301 let timeoutTimer = null; 302 if (timeout) timeoutTimer = setTimeout(() => reject(timeoutError), timeout); 303 try { 304 return await Promise.race([promise, timeoutPromise]); 305 } finally { 306 if (timeoutTimer) clearTimeout(timeoutTimer); 307 } 308} 309 310async function readProtocolStream( 311 client: CDPSession, 312 handle: string, 313 path?: string 314): Promise<Buffer> { 315 if (!isNode && path) { 316 throw new Error('Cannot write to a path outside of Node.js environment.'); 317 } 318 319 const fs = isNode ? await importFSModule() : null; 320 321 let eof = false; 322 let fileHandle: import('fs').promises.FileHandle; 323 324 if (path && fs) { 325 fileHandle = await fs.promises.open(path, 'w'); 326 } 327 const bufs = []; 328 while (!eof) { 329 const response = await client.send('IO.read', { handle }); 330 eof = response.eof; 331 const buf = Buffer.from( 332 response.data, 333 response.base64Encoded ? 'base64' : undefined 334 ); 335 bufs.push(buf); 336 if (path && fs) { 337 await fs.promises.writeFile(fileHandle, buf); 338 } 339 } 340 if (path) await fileHandle.close(); 341 await client.send('IO.close', { handle }); 342 let resultBuffer = null; 343 try { 344 resultBuffer = Buffer.concat(bufs); 345 } finally { 346 return resultBuffer; 347 } 348} 349 350/** 351 * Loads the Node fs promises API. Needed because on Node 10.17 and below, 352 * fs.promises is experimental, and therefore not marked as enumerable. That 353 * means when TypeScript compiles an `import('fs')`, its helper doesn't spot the 354 * promises declaration and therefore on Node <10.17 you get an error as 355 * fs.promises is undefined in compiled TypeScript land. 356 * 357 * See https://github.com/puppeteer/puppeteer/issues/6548 for more details. 358 * 359 * Once Node 10 is no longer supported (April 2021) we can remove this and use 360 * `(await import('fs')).promises`. 361 */ 362async function importFSModule(): Promise<typeof import('fs')> { 363 if (!isNode) { 364 throw new Error('Cannot load the fs module API outside of Node.'); 365 } 366 367 const fs = await import('fs'); 368 if (fs.promises) { 369 return fs; 370 } 371 return fs.default; 372} 373 374export const helper = { 375 evaluationString, 376 pageBindingInitString, 377 pageBindingDeliverResultString, 378 pageBindingDeliverErrorString, 379 pageBindingDeliverErrorValueString, 380 makePredicateString, 381 readProtocolStream, 382 waitWithTimeout, 383 waitForEvent, 384 isString, 385 isNumber, 386 importFSModule, 387 addEventListener, 388 removeEventListeners, 389 valueFromRemoteObject, 390 getExceptionMessage, 391 releaseObject, 392}; 393