Home Reference Source

lib/browser/Browser.js

const util = require('util')
const { URL } = require('url')
const EventEmitter = require('eventemitter3')
const { helper, assert } = require('../helper')
const Events = require('../Events')
const BrowserContext = require('./BrowserContext')
const Target = require('../Target')
const TaskQueue = require('../TaskQueue')
const {
  adaptChromeRemoteInterfaceClient,
  CRIConnection
} = require('../connection')
const CRIExtra = require('../chromeRemoteInterfaceExtra')
const {
  getWindowBounds,
  closeTarget,
  getWindowForTarget,
  setWindowBounds
} = require('../__shared')

async function dummyCloseCB () {}

/**
 * @typedef {Object} BrowserInitOptions
 * @property {Object} [process]
 * @property {?Array<string>} [contextIds]
 * @property {?boolean} [ignoreHTTPSErrors]
 * @property {?Object} [defaultViewport]
 * @property {?(function():Promise)} [closeCallback]
 * @property {?EnabledExtras} [additionalDomains]
 * @property {?string} [browserWSEndpoint]
 */

/**
 * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser
 */
class Browser extends EventEmitter {
  /**
   * @param {Chrome|CRIConnection|Object} connection
   * @param {BrowserInitOptions} [initOpts = {}]
   * @returns {Browser}
   */
  static async create (connection, initOpts = {}) {
    const browser = new Browser(
      connection instanceof CRIConnection
        ? connection
        : adaptChromeRemoteInterfaceClient(connection),
      Object.assign({ contextIds: [] }, initOpts)
    )
    await connection.send('Target.setDiscoverTargets', { discover: true })
    return browser
  }

  /**
   * @param {string} browserWSEndpoint
   * @param {BrowserInitOptions} [initOpts = {}]
   * @return {Browser}
   */
  static async connect (browserWSEndpoint, initOpts = {}) {
    const url = new URL(browserWSEndpoint)
    const port = url.port ? parseInt(url.port, 10) : 9222
    const connection = await CRIExtra({
      host: url.host,
      port: port,
      target: browserWSEndpoint
    })
    const { browserContextIds } = await connection.send(
      'Target.getBrowserContexts'
    )
    return Browser.create(
      connection,
      Object.assign({}, initOpts, {
        contextIds: browserContextIds
      })
    )
  }

  /**
   * @param {!Chrome|CRIConnection} connection
   * @param {BrowserInitOptions} initOpts
   */
  constructor (
    connection,
    {
      contextIds,
      ignoreHTTPSErrors,
      defaultViewport,
      closeCallback,
      additionalDomains,
      process
    }
  ) {
    super()
    /**
     * @type {boolean}
     */
    this._ignoreHTTPSErrors = ignoreHTTPSErrors

    /**
     * @type {?Object}
     */
    this._defaultViewport = defaultViewport

    /**
     * @type {?Object}
     */
    this._process = process

    /**
     * @type {TaskQueue}
     * @private
     */
    this._screenshotTaskQueue = new TaskQueue()

    /**
     * @type {!Chrome|CRIConnection}
     * @private
     */
    this._connection = connection

    /**
     * @type {*|dummyCloseCB}
     * @private
     */
    this._closeCallback = closeCallback || dummyCloseCB

    /**
     * @type {?EnabledExtras}
     */
    this._additionalDomains = additionalDomains

    /**
     * @type {BrowserContext}
     * @private
     */
    this._defaultContext = new BrowserContext(this._connection, this, null)

    /** @type {Map<string, BrowserContext>} */
    this._contexts = new Map()

    /**
     * @type {Map<string, string>}
     * @private
     */
    this._webPermissionToProtocol = new Map([
      ['geolocation', 'geolocation'],
      ['midi', 'midi'],
      ['notifications', 'notifications'],
      ['push', 'push'],
      ['camera', 'videoCapture'],
      ['videoCapture', 'videoCapture'],
      ['microphone', 'audioCapture'],
      ['audioCapture', 'audioCapture'],
      ['background-sync', 'backgroundSync'],
      ['backgroundSync', 'backgroundSync'],
      ['background-fetch', 'backgroundFetch'],
      ['backgroundFetch', 'backgroundFetch'],
      ['flash', 'flash'],
      ['ambient-light-sensor', 'sensors'],
      ['sensors', 'sensors'],
      ['notifications', 'notifications'],
      ['protected-media-identifier', 'protectedMediaIdentifier'],
      ['protectedMediaIdentifier', 'protectedMediaIdentifier'],
      ['accelerometer', 'sensors'],
      ['gyroscope', 'sensors'],
      ['magnetometer', 'sensors'],
      ['accessibility-events', 'accessibilityEvents'],
      ['accessibilityEvents', 'accessibilityEvents'],
      ['clipboard-read', 'clipboardRead'],
      ['clipboardRead', 'clipboardRead'],
      ['clipboard-write', 'clipboardWrite'],
      ['clipboardWrite', 'clipboardWrite'],
      ['payment-handler', 'paymentHandler'],
      ['paymentHandler', 'paymentHandler'],
      ['idleDetection', 'idleDetection'],
      // chrome-specific permissions we have.
      ['midi-sysex', 'midiSysex']
    ])

    for (let i = 0; i < contextIds.length; i++) {
      this._contexts.set(
        contextIds[i],
        new BrowserContext(this._connection, this, contextIds[i])
      )
    }

    /** @type {Map<string, Target>} */
    this._targets = new Map()
    this._connection.on(
      this._connection.$$disconnectEvent || Events.CRIClient.Disconnected,
      () => this.emit(Events.Browser.Disconnected)
    )
    this._connection.on('Target.targetCreated', this._targetCreated.bind(this))
    this._connection.on(
      'Target.targetDestroyed',
      this._targetDestroyed.bind(this)
    )
    this._connection.on(
      'Target.targetInfoChanged',
      this._targetInfoChanged.bind(this)
    )
  }

  /**
   * @return {?Object}
   */
  process () {
    return this._process
  }

  /**
   * Returns all known targets
   * @return {Array<Target>}
   */
  targets () {
    return Array.from(this._targets.values()).filter(
      target => target._isInitialized
    )
  }

  /**
   * Returns the target associated with the browser
   * @return {!Target}
   */
  target () {
    return this.targets().find(target => target.type() === 'browser')
  }

  /**
   * Returns the target associated with the supplied target id if we know about it
   * @param {string} targetId
   * @return {?Target}
   * @since chrome-remote-interface-extra
   */
  getTargetById (targetId) {
    return this._targets.get(targetId)
  }

  /**
   * Disconnect from the browser After calling disconnect, the {@link Browser} object is considered disposed
   * and cannot be used anymore
   */
  disconnect () {
    this._connection.dispose()
  }

  /**
   * Returns an array of all open browser contexts. In a newly created browser, this will return a single instance of {@link BrowserContext}
   * @return {Array<BrowserContext>}
   */
  browserContexts () {
    return [this._defaultContext, ...Array.from(this._contexts.values())]
  }

  /**
   * Returns the default browser context. The default browser context can not be closed
   * @return {!BrowserContext}
   */
  defaultBrowserContext () {
    return this._defaultContext
  }

  /**
   * @return {string}
   */
  wsEndpoint () {
    return this._connection.webSocketUrl
  }

  /**
   * Closes the target specified by the targetId. If the target is a page that gets closed too.
   * Returns T/F to indicate if the command was successful
   * @param {string} targetId - The id of the target to be closed
   * @param {boolean} [throwOnError] - If true and the command was un-successful the caught error is thrown
   * @return {Promise<boolean>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Target#method-closeTarget
   * @since chrome-remote-interface-extra
   */
  closeTarget (targetId, throwOnError) {
    return closeTarget(this._connection, targetId, throwOnError)
  }

  /**
   * Returns version information
   * @return {Promise<{protocolVersion: string, product: string, revision: string, userAgent: string, jsVersion: string}>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-getVersion
   * @since chrome-remote-interface-extra
   */
  versionInfo () {
    return this._connection.send('Browser.getVersion', {})
  }

  /**
   * Get Chrome histograms. EXPERIMENTAL
   * Optional options:
   *  - query: Requested substring in name. Only histograms which have query as a substring in their name are extracted.
   *    An empty or absent query returns all histograms.
   *  - delta: If true, retrieve delta since last call
   * @param {BrowserHistogramQuery} [options]
   * @return {Promise<Array<CDPHistogram>>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-getHistograms
   * @since chrome-remote-interface-extra
   */
  getHistograms (options) {
    return this._connection.send('Browser.getHistograms', options || {})
  }

  /**
   * Get a Chrome histogram by name. EXPERIMENTAL
   * @param {string} name - Requested histogram name
   * @param {boolean} [delta] - If true, retrieve delta since last call
   * @return {Promise<CDPHistogram>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-getHistogram
   * @since chrome-remote-interface-extra
   */
  getHistogram (name, delta) {
    assert(
      helper.isString(name),
      `The name param must be of type "String", received type ${typeof delta}`
    )
    if (delta != null) {
      assert(
        helper.isBoolean(delta),
        `The delta param must be of type "boolean", received type ${typeof delta}`
      )
    }
    return this._connection.send('Browser.getHistogram', { name, delta })
  }

  /**
   * Get position and size of the browser window. EXPERIMENTAL
   * @param {number} windowId
   * @return {Promise<WindowBounds>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-getWindowBounds
   * @since chrome-remote-interface-extra
   */
  getWindowBounds (windowId) {
    return getWindowBounds(this._connection, windowId)
  }

  /**
   * Get the browser window that contains the target. EXPERIMENTAL
   * @param {string} [targetId] - Optional target id of the target to receive the window id and its bound for.
   * If called as a part of the session, associated targetId is used.
   * @return {Promise<{bounds: WindowBounds, windowId: number}>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-getWindowForTarget
   * @since chrome-remote-interface-extra
   */
  getWindowForTarget (targetId) {
    return getWindowForTarget(this._connection, targetId)
  }

  /**
   * Set position and/or size of the browser window. EXPERIMENTAL
   * @param {number} windowId - An browser window id
   * @param {WindowBounds} bounds - New window bounds. The 'minimized', 'maximized' and 'fullscreen' states cannot be combined with 'left', 'top', 'width' or 'height'. Leaves unspecified fields unchanged.
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-setWindowBounds
   * @since chrome-remote-interface-extra
   */
  async setWindowBounds (windowId, bounds) {
    await setWindowBounds(this._connection, windowId, bounds)
  }

  /**
   * Grant specific permissions to the given origin and reject all others. EXPERIMENTAL
   * @param {string} origin - The origin these permissions will be granted for
   * @param {Array<string>} permissions - Array of permission overrides
   * @param {string} [browserContextId] - BrowserContext to override permissions. When omitted, default browser context is used
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-grantPermissions
   */
  async grantPermissions (origin, permissions, browserContextId) {
    const actualPermissions = permissions.map(permission => {
      const protocolPermission = this._webPermissionToProtocol.get(permission)
      if (!protocolPermission) {
        throw new Error('Unknown permission: ' + permission)
      }
      return protocolPermission
    })
    await this._connection.send('Browser.grantPermissions', {
      origin,
      browserContextId: browserContextId || undefined,
      permissions: actualPermissions
    })
  }

  /**
   * Reset all permission management for all origins. EXPERIMENTAL
   * @param {string} [browserContextId] - BrowserContext to reset permissions. When omitted, default browser context is used.
   * @return {Promise<void>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Browser#method-resetPermissions
   */
  async resetPermissions (browserContextId) {
    await this._connection.send('Browser.resetPermissions', {
      browserContextId: browserContextId || undefined
    })
  }

  /**
   * Returns all browser contexts created with Target.createBrowserContext method. EXPERIMENTAL
   * @return {Promise<Array<string>>} An array of browser context ids
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-getBrowserContexts
   * @since chrome-remote-interface-extra
   */
  async listBrowserContexts () {
    const { browserContextIds } = await this._connection.send(
      'Target.getBrowserContexts',
      {}
    )
    return browserContextIds
  }

  /**
   * Retrieves a list of available targets
   * @return {Promise<Array<CDPTargetInfo>>}
   * @see https://chromedevtools.github.io/devtools-protocol/tot/Target#method-getTargets
   * @since chrome-remote-interface-extra
   */
  async listTargets () {
    const { targetInfos } = await this._connection.send('Target.getTargets')
    return targetInfos
  }

  /**
   * This searches for a target in all browser contexts
   * @param {function(target: Target):boolean} predicate
   * @param {{timeout?: number}} [options = {}]
   * @return {Promise<Target>}
   * @example
   * // finding a target for a page opened via window.open
   * await page.evaluate(() => window.open('https://www.example.com/'))
   * const newWindowTarget = await browser.waitForTarget(target => target.url() === 'https://www.example.com/')
   */
  async waitForTarget (predicate, options = {}) {
    const { timeout = 30000 } = options
    const existingTarget = this.targets().find(predicate)
    if (existingTarget) return existingTarget
    let done
    const targetPromise = new Promise(resolve => (done = resolve))
    this.on(Events.Browser.TargetCreated, check)
    this.on(Events.Browser.TargetChanged, check)
    try {
      if (!timeout) return await targetPromise
      return await helper.waitWithTimeout(targetPromise, 'target', timeout)
    } finally {
      this.removeListener(Events.Browser.TargetCreated, check)
      this.removeListener(Events.Browser.TargetChanged, check)
    }

    /**
     * @param {!Target} target
     */
    function check (target) {
      if (predicate(target)) done(target)
    }
  }

  /**
   * Creates a new incognito browser context. This won't share cookies/cache with other browser contexts
   * @return {Promise<BrowserContext>}
   */
  async createIncognitoBrowserContext () {
    const { browserContextId } = await this._connection.send(
      'Target.createBrowserContext'
    )
    const context = new BrowserContext(this._connection, this, browserContextId)
    this._contexts.set(browserContextId, context)
    return context
  }

  /**
   * An array of all pages inside the {@link Browser}. In case of multiple browser contexts, the method will return
   * an array with all the pages in all browser contexts
   * @return {Promise<Array<Page>>}
   */
  async pages () {
    const contextPages = await Promise.all(
      this.browserContexts().map(context => context.pages())
    )
    if (contextPages.flat) {
      return contextPages.flat(Infinity)
    }
    // Flatten array.
    return contextPages.reduce((acc, x) => acc.concat(x), [])
  }

  /**
   * @return {Promise<string>}
   */
  async version () {
    const version = await this.versionInfo()
    return version.product
  }

  /**
   * @return {Promise<string>}
   */
  async userAgent () {
    const version = await this.versionInfo()
    return version.userAgent
  }

  /**
   * Closes all of its pages (if any were opened) and the Browser object itself is considered to be
   * disposed and cannot be used anymore
   * @return {Promise<void>}
   */
  async close () {
    await this._closeCallback.call(null)
    this.disconnect()
  }

  /**
   * Promise which resolves to a new {@link Page} object. The {@link Page} is created in a default browser context
   * @return {Promise<Page>}
   */
  async newPage () {
    return this._defaultContext.newPage()
  }

  /**
   * @param {?string} contextId
   */
  async _disposeContext (contextId) {
    await this._connection.send('Target.disposeBrowserContext', {
      browserContextId: contextId || undefined
    })
    this._contexts.delete(contextId)
  }

  /**
   * @param {!Object} event
   */
  async _targetCreated (event) {
    const targetInfo = event.targetInfo
    const { browserContextId } = targetInfo
    const context =
      browserContextId && this._contexts.has(browserContextId)
        ? this._contexts.get(browserContextId)
        : this._defaultContext

    const target = new Target({
      targetInfo,
      browserContext: context,
      sessionFactory: () => this._connection.createSession(targetInfo),
      pageOpts: {
        ignoreHTTPSErrors: this._ignoreHTTPSErrors,
        defaultViewport: this._defaultViewport,
        screenshotTaskQueue: this._screenshotTaskQueue,
        additionalDomains: this._additionalDomains
      }
    })
    assert(
      !this._targets.has(event.targetInfo.targetId),
      'Target should not exist before targetCreated'
    )
    this._targets.set(event.targetInfo.targetId, target)

    if (await target._initializedPromise) {
      this.emit(Events.Browser.TargetCreated, target)
      context.emit(Events.BrowserContext.TargetCreated, target)
    }
  }

  /**
   * @param {{targetId: string}} event
   */
  async _targetDestroyed (event) {
    const target = this._targets.get(event.targetId)
    target._initializedCallback(false)
    this._targets.delete(event.targetId)
    target._closedCallback()
    if (await target._initializedPromise) {
      this.emit(Events.Browser.TargetDestroyed, target)
      target
        .browserContext()
        .emit(Events.BrowserContext.TargetDestroyed, target)
    }
  }

  /**
   * @param {?string} contextId
   * @return {Promise<Page>}
   */
  async _createPageInContext (contextId) {
    const { targetId } = await this._connection.send('Target.createTarget', {
      url: 'about:blank',
      browserContextId: contextId || undefined
    })
    const target = await this._targets.get(targetId)
    assert(await target._initializedPromise, 'Failed to create target for page')
    return target.page()
  }

  /**
   * @param {!Object} event
   */
  _targetInfoChanged (event) {
    const target = this._targets.get(event.targetInfo.targetId)
    assert(target, 'target should exist before targetInfoChanged')
    const previousURL = target.url()
    const wasInitialized = target._isInitialized
    target._targetInfoChanged(event.targetInfo)
    if (wasInitialized && previousURL !== target.url()) {
      this.emit(Events.Browser.TargetChanged, target)
      target.browserContext().emit(Events.BrowserContext.TargetChanged, target)
    }
  }

  /**
   * @return {string}
   */
  toString () {
    return util.inspect(this, { depth: null })
  }

  /**
   * @return {Object}
   */
  toJSON () {
    return {
      defaultContext: this._defaultContext,
      contexts: this._contexts,
      targets: this._targets,
      ignoreHTTPSErrors: this._ignoreHTTPSErrors,
      defaultViewport: this._defaultViewport
    }
  }

  /** @ignore */
  // eslint-disable-next-line space-before-function-paren
  [util.inspect.custom](depth, options) {
    if (depth < 0) {
      return options.stylize('[Browser]', 'special')
    }

    const newOptions = Object.assign({}, options, {
      depth: options.depth == null ? null : options.depth - 1
    })
    const inner = util.inspect(
      {
        defaultContext: this._defaultContext,
        contexts: this._contexts,
        targets: this._targets,
        ignoreHTTPSErrors: this._ignoreHTTPSErrors,
        defaultViewport: this._defaultViewport
      },
      newOptions
    )
    return `${options.stylize('Browser', 'special')} ${inner}`
  }
}

/**
 * @type {Browser}
 */
module.exports = Browser