Home Reference Source

lib/browser/puppeteer/puppeteer_page.js

const path = require('path');
const fs = require('fs');
const EventEmitter = require('events');
const BrowserPage = require("../interface/browser_page");
const BrowserSocket = require("../interface/browser_socket");
const PuppeteerFinderExtention = require("./puppeteer_finder_extention");
const {config} = require("../../utils/config");
const DateFormat = require('../../utils/date_format');
const Logger = require('../../utils/logger')

const TIMEOUT_MSEC = config.mocha.timeout;
const DOWNLOAD_PATH = path.resolve(config.fileDownloadDir);

class PuppeteerPage extends BrowserPage {
  constructor(window){
    super();
    this._window = window;
    const browserSocket = this._browserSocket = new PuppeteerSocket();

    this._window.on('readyForPage', ()=>{
      this.nativeObject.exposeFunction('invokeBrowserSocket', (method, ...args)=>{
        // TODO
        // arguments may be given as ['[','"','s','e','n','d',,,,,
        if(method == "["){
           args = JSON.parse(method + args.join(""));
           method = args.shift();
        }
        return browserSocket[method](...args);
      })
      this.nativeObject.evaluateOnNewDocument(()=>{
        window.browserSocket = {
          on: (...args)=>{ window.invokeBrowserSocket('on', ...args) },
          once: (...args)=>{ window.invokeBrowserSocket('once', ...args) },
          removeAllListeners: (...args)=>{ window.invokeBrowserSocket('removeAllListeners', ...args) },
          send: (...args)=>{ window.invokeBrowserSocket('send', ...args) },
        }
      });
      this._setDownloadPath(DOWNLOAD_PATH);
    })
  }
  on(eventName, callback){
    this._addEventListener(eventName, callback, false)
  }
  once(eventName, callback){
    this._addEventListener(eventName, callback, true)
  }
  _addEventListener(eventName, callback, once = true){
    if(eventName in PuppeteerPage.EventMappings){
      this.nativeObject[once ? 'once' : 'on'](PuppeteerPage.EventMappings[eventName], callback)
    }else{
      super[once ? 'once' : 'on'](eventName, callback)
    }
  }
  removeAllListeners(eventName){
    if(eventName in PuppeteerPage.EventMappings){
      this.nativeObject.removeAllListeners(PuppeteerPage.EventMappings[eventName])
    }else{
      super.removeAllListeners(eventName);
    }
  }
  /**
   * @return {Window}
   */
  get window(){
    return this._window;
  }
  /**
   * @return {object}
   */
  get nativeObject(){
    return this._window.browserWindow;
  }
  /**
   * @return {BrowserSocket}
   */
  get socket(){
    return this._browserSocket;
  }
  /**
   * @return {Finder}
   */
  get FinderClass(){
    return PuppeteerFinderExtention;
  }
  /**
   * @return {Promise<BrowserPage>}
   */
  loadURL(url, options = {}){
    return this.nativeObject.goto(url);
  }
  /**
   * @return {string}
   */
  getURL(){
    return this.nativeObject.url();
  }
  /**
   * @return {Promise<object>}
   */
  reload(){
    return this.nativeObject.reload();
  }
  /**
   * @return {Promise<string|number|boolean>}
   */
  executeJavaScript(code, ...args){
    return this.nativeObject.evaluate(code, ...args);
  }
  /**
   * show developer tool, not supported yet.
   */
  openDevTools(){ /*ignoe*/ }
  /**
   * @private
   */
  _setDownloadPath(downloadPath){
    if(config.puppeteer.product == "chrome"){
      return this.nativeObject._client().send(
        'Page.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadPath }
      );
    }else{
      return Promise.resolve();
    }
  }
  /**
   * @return {Promise<object>}
   */
   waitForDownload(action, options){

    const downloadPath = options.downloadPath || path.join(DOWNLOAD_PATH, DateFormat.toTimepstampMsecString(new Date()))
    const timerIds = {}
    const callbacks = {};

    const clearPageCallback = ()=>{
      if(callbacks['response']) this.nativeObject.removeListener('response', callbacks['response']);
      if(callbacks['requestfinished']) this.nativeObject.removeListener('requestfinished', callbacks['requestfinished']);
      clearTimeout(timerIds['timeout']);
      clearInterval(timerIds['interval']);
      return true;
    }

    return new Promise((resolve, reject)=>{
      timerIds['timeout'] = setTimeout(()=>{ clearPageCallback() && reject(new Error('timeout')) }, TIMEOUT_MSEC);

      callbacks['response'] = (response)=>{
        let contentDisposition = response.headers()["content-disposition"];
        if(!contentDisposition) return;

        const matched1 = contentDisposition.match(/filename\*=(?:utf|UTF)-?8''(.*)$/);
        const matched2 = contentDisposition.match(/filename=\"(.*)\"$/);
        if(!matched1 && !matched2) {
          Logger.warn(`Content-Disposition header don't include filename attribute. [${contentDisposition}]`);
        }
        if(matched1) {
          matched1[1] = decodeURIComponent(matched1[1]);
        }
        const filename = matched1 ? matched1[1] : (matched2 ? matched2[1] : "no name");
        const fileInfo = { filename: filename, savedFilePath: path.resolve(path.join(downloadPath, filename)) };

        // wait for having downloaded in downloadPath
        if(contentDisposition.match(/^attachment;/)){

          timerIds['interval'] = setInterval(()=>{
            fs.readdir(downloadPath, (err, files)=>{
              if(err){
                if(err.code === 'ENOENT') return; // have not created directory yet.
                return clearPageCallback() && reject(new Error(err));
              }
              if (files.length === 0) return; // no downloaded file
              // In downloading, the file are given name to match for /.crdownload/
              if(Array.from(files).some(file => /.*\.crdownload$/.test(file))) return;

              // files exits, and not downloading now -> completed
              clearPageCallback() && resolve(fileInfo);
            });
          }, 100); // 100msec
        }
        if(contentDisposition.match(/^inline;/)){
          return fs.promises.mkdir(downloadPath)
          .then(()=>{
            return response.buffer()
          })
          .then((buffer)=>{
            return fs.promises.writeFile(fileInfo.savedFilePath, buffer);
          })
          .then(()=>{
            clearPageCallback() && resolve(fileInfo);
          })
          .catch(reject);
        }
      };

      this.nativeObject.on('response', callbacks['response']);
      this._setDownloadPath(downloadPath).then(()=>{ action && action() })
    }); // end Promise
  }

  /**
   * @return {Promise<string>} resolved by confirm message
   */
  _waitForDialog(dialogType, action, message = undefined, isClickOK = false){
    return new Promise((resolve, reject)=>{

      let dialogCallback;

      let timeoutId = setTimeout(()=>{
        this.nativeObject.removeListener('dialog', dialogCallback);
        reject(new Error('timeout'));
      }, TIMEOUT_MSEC);

      dialogCallback = (dialog)=>{

        let actualType = dialog.type();
        let actualMessage = dialog.message();

        (isClickOK ? dialog.accept() : dialog.dismiss())
        .then(()=>{
          if(dialogType != actualType){
            return Logger.warn(`Unexpected dialog type: ${actualType}`);
          }
          if(message && message != actualMessage){
            return Logger.warn(`expected message is "${message}", but actual is "${actualMessage}"`);
          }
          clearTimeout(timeoutId);
          this.nativeObject.removeListener('dialog', dialogCallback);
          resolve(actualMessage);
        })
        .catch(reject);
      };

      this.nativeObject.on('dialog', dialogCallback);
      action();
    });
  }

  /**
   * @return {Promise<string>} resolved by confirm message
   */
  waitForConfirm(action, message = undefined, isClickOK = false){
    return this._waitForDialog('confirm', action, message, isClickOK);
  }

  /**
   * @return {Promise<string>} resolved by alert message
   */
  waitForAlert(action, message = undefined){
    return this._waitForDialog('alert', action, message, true);
  }

  takeScreenshot(filename){
    return this.nativeObject.screenshot({ path: filename, type: "png" });
  }
}

class PuppeteerSocket extends BrowserSocket {
  constructor(){
    super();
    this.emitter = new EventEmitter();
  }
  on(channel, callback){
    return this.emitter.on(channel, callback);
  }
  once(channel, callback){
    return this.emitter.once(channel, callback);
  }
  removeAllListeners(channel){
    return this.emitter.removeAllListeners(channel);
  }
  send(channel, ...args){
    return this.emitter.emit(channel, ...args)
  }
}

PuppeteerPage.EventMappings = {
  [BrowserPage.Events.Load]: 'domcontentloaded',
  [BrowserPage.Events.LoadError]: 'requestfailed',
}

module.exports = PuppeteerPage;