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