Home Reference Source

lib/page/page.js

const path = require('path');
const {URL} = require('url');
const fs = require('fs');
const Finder = require('./finder');
const Assert = require('./assert');
const PageMixin = require('./page_mixin');
const pageWalker = require('../page_walker');
const {config} = pageWalker;
const Browser = require('../browser/interface/browser');
const BrowserPage = require('../browser/interface/browser_page');
const mixin = require('../utils/mixin');
const DateFormat = require('../utils/date_format');

const TIMEOUT_MSEC = pageWalker.config.mocha.timeout;

class Page {
  constructor(browserPage, context = undefined){
    this._browserPage = browserPage;
    this._context = context;
  }
  /**
   * @return {BrowserPage} BrowserPage Object(Instance will be relayed on Browser used)
   */
  get browserPage(){ return this._browserPage }
  /**
   * @private
   */
  get context(){ return this._context }
  /**
   * @return {Window} Window Object which contains this page.
   */
  get window(){ return this._browserPage.window }
  /**
   * @return {object} Page Object provided by Each Browser
   */
  get webContents(){ return this._browserPage.nativeObject }
  /**
   * @return {Assert} Assert Object
   * @deprecated
   */
  get assert(){ return this._assert = this._assert || new Assert(this, config) }
  /**
   * URL of current page
   * @return {string}
   */
  get url(){
    return this.browserPage.getURL();
  }
  /**
   * Reload current page.
   * @return {Promise<object>}
   */
  reload(){
    return this.browserPage.reload();
  }
  /**
   * Load new page which is URL given, and returned Promise is resolved when finished load.
   * @return {Promise}
   */
  load(url, options = {}){
    let loadURL = (urlString)=>{
      return this.waitForPageLoad(()=>{ if(urlString) return this.browserPage.loadURL(urlString, options) })
    };
    if(this.window.isReadyForPage()){
      return loadURL(url)
    }else{
      return new Promise((resolve, reject)=>{
        this.window.once('readyForPage', ()=>{ loadURL(url).then(resolve).catch(reject) });
      });
    }
  }
  /**
   * Wait loading for contents in new page.
   * @param {function} action - A function representing the operation of loading new page.
   * @return {Promise}
   */
  waitForPageLoad(action){
    if(this._context){
      return this.waitForBrowserSocket({ channel: 'iframe-page-load' }, ()=>{
        let invokeTrigger = action ?
          `cw.addEventListener('unload', ()=>{
            setTimeout(()=>{ cw.addEventListener('DOMContentLoaded', sendEventFunc) }, 0);
          });` :
          `setInterval(()=> {
            if (cw.document.readyState == 'complete') {
              sendEventFunc();
            }
          }, 100);`;
        return this.executeJs(`{
          let cw = (${this._context});
          let sendEventFunc = ()=>{ window.browserSocket.send('iframe-page-load', 'loaded') };
          ${invokeTrigger}
        }`)
        .then(()=>{
          if (action && typeof action == 'function') {
            return action();
          }
        })
      })
    }
    return this.newPromiseWithCheckReady((resolve, reject)=>{
      let timeoutId = setTimeout(()=>{ reject(new Error('timeout in waitForPageLoad')) }, TIMEOUT_MSEC);

      let clearListenerAndTimer = ()=>{
        this.browserPage.removeAllListeners(BrowserPage.Events.Load);
        this.browserPage.removeAllListeners(BrowserPage.Events.LoadError);
        clearTimeout(timeoutId);
      }

      this.browserPage.once(BrowserPage.Events.Load, (event)=>{
        clearListenerAndTimer();
        resolve(this)
      })
    }, action)
  }
  /**
   * Wait file-download completed and resolve the promise with result object(contains filename, savedFilePath)
   * A File is saved in config.fileDownloadDir with timestamp string like '20171225002540_print.pdf'
   * @param {function} action - A function representing the operation of file download
   * @param {object} options - options for downloading
   * @return {Promise<object>} object have property "filename" and "savedFilePath"
   * @example
   *   const item = await page.waitForDownload(()=>{
   *     ...
   *   })
   *   assert.equal(await (util.promisify(fs.readFile))(item.savedFilePath, 'utf-8'), ...);
   */
  waitForDownload(action, options = {}){
    return this.newPromiseWithCheckReady((resolve, reject)=>{
      this.browserPage.waitForDownload(action, options).then(resolve).catch(reject);
    })
  }
  /**
   * Wait new-window opend and resolve the promise with Window object.
   * @param {function} action - A function representing the operation of open new window
   * @return {Promise}
   * @example
   *   const newWin = await page.waitForNewWindow(()=>{
   *     ...
   *   })
   *   newWin.page.find(...).click();
   *   newWin.close();
   */
  waitForNewWindow(action){
    return this.newPromiseWithCheckReady((resolve, reject)=>{
      let windowOpenCallback;
      let timeoutId = setTimeout(()=>{
        pageWalker.browser.removeListener(Browser.Events.NewWindow, windowOpenCallback);
        reject('timeout in waitForNewWindow');
      }, TIMEOUT_MSEC);

      windowOpenCallback = (browserWindow)=>{
        clearTimeout(timeoutId);
        if(pageWalker.getWindowByBrowserWindow(browserWindow)){
          resolve(pageWalker.getWindowByBrowserWindow(browserWindow));
        }else{
          reject(new Error(`no window found!`));
        }
      };
      pageWalker.browser.once(Browser.Events.NewWindow, windowOpenCallback);
    }, action)
  }
  /**
   * Wait for appearance of element specified by css-selector.
   * @param {string} cssSelector - CSS Selector string for finding element.
   * @param {function} action -  - A function representing the operation
   * @return {Promise}
   */
  waitForSelector(cssSelector, action){
    return this.waitForFinder(this.find(cssSelector), action);
  }
  /**
   * Wait for appearance of element specified by Finder object.
   * @param {Finder} finder - Finder object for finding element.
   * @param {function} action -  - A function representing the operation
   * @return {Promise}
   */
  waitForFinder(finder, action){
    const channel = 'ch-finder-asynchronous';
    finder.allowNotFound(true);

    return this.waitForBrowserSocket({ channel: channel, timeout: TIMEOUT_MSEC + 1000 }, ()=>{
      return this.executeJs(`(()=>{
        function elementCountByFinder(){
          ${finder.toJsCode()}
        };
        function sendIfExists(){
          try{
            if(elementCountByFinder() > 0){
              browserSocket.send('${channel}', 'found');
              return true;
            }
          }catch(e){
            browserSocket.send('${channel}', 'unknown error');
            return true;
          }
        }

        if(sendIfExists()) return;

        let timerId = setTimeout(()=>{
          browserSocket.send('${channel}', 'timeout');
          observer.disconnect();
        }, ${TIMEOUT_MSEC});

        const observer = new MutationObserver((mutations)=>{
          if(sendIfExists()) {
            clearTimeout(timerId);
            return observer.disconnect();
          }
        });
        observer.observe(document.body, { childList: true, subtree: true });

      })()`)
      .then(()=>{ action && action() })
    })
    .then((socketResult)=>{
      if(socketResult == 'found') return;
      throw new Error(socketResult == 'timeout' ? 'timeout' : 'unknown error');
    });
  }
  /**
   * @param {object} socketOption - specify channel property, and timeout property.
   * @param {function} action - A function representing the operation of sending Browser Socket Message
   * @return {Promise}
   */
  waitForBrowserSocket(socketOption, action){
    if(!action && typeof socketOption === 'function'){
      action = socketOption;
      socketOption = { channel: 'asynchronous-message-channel' };
    }
    return this.newPromiseWithCheckReady((resolve, reject)=>{
      let timeoutId = setTimeout(()=>{ reject('timeout in waitForBrowserSocket') }, socketOption.timeout || TIMEOUT_MSEC);
      this.browserPage.socket.removeAllListeners(socketOption.channel);
      this.browserPage.socket.once(socketOption.channel, (arg)=>{
        clearTimeout(timeoutId);
        resolve(arg);
      });
    }, action)
  }
  /**
   * @param {function} action - A function representing the operation of sending Ajax request
   * @return {Promise}
   */
  waitForAjaxDone(action){
    return this.waitForBrowserSocket({ channel: "notify-ajax-done" }, ()=>{
      return this.executeJs(`(()=>{
        let _XMLHttpRequest = ${this.context ? this.context : 'window'}.XMLHttpRequest;
        let _browserSocket = ${this.context ? 'parent' : 'window'}.browserSocket;
        let __original_open = _XMLHttpRequest.prototype.open;

        _XMLHttpRequest.prototype.open = function(){
          let timeoutId = setTimeout(()=>{ _browserSocket.send('notify-ajax-done', 'timeout') }, ${TIMEOUT_MSEC})
          this.addEventListener('readystatechange', function(){
            if(this.readyState == 4){
              clearTimeout(timeoutId);
              setTimeout(()=>{ _browserSocket.send('notify-ajax-done', 'ajaxComplete') }, 0);
            }
          });
          __original_open.apply(this, arguments);
        };
      })();`)
      .then(()=>{ return action && action() })
    })
    .then((result)=>{
      if(result == 'ajaxComplete') return;
      if(result == 'timeout') throw new Error('timeout in waitForAjaxDone');
      throw new Error(`unknown error: ${result}`);
    });
  }
  /**
   * @param {object} confirmOption - specify message property(default undefined), and isClickOK property(default true)
   * @param {function} action - A function representing the operation of open confirm dialog
   * @return {Promise}
   * @example
   *   await page.waitConfirmDialog({ message: "Are you OK?", isClickOK: false }, ()=>{
   *     page.find("button.submit-form").click()
   *   })
   *
   *   await page.waitLoading(async ()=>{
   *     await page.waitForConfirm(async ()=>{
   *       await page.find("button.submit-form").click()
   *     })
   *   })
   */
  waitForConfirm(confirmOption, action){
    const defaultOption = { message: "", isClickOK: true };
    if(typeof action == "undefined" && typeof confirmOption == "function"){
      action = confirmOption
      confirmOption = defaultOption;
    }
    let message = confirmOption.message || defaultOption.message;
    let isClickOK = ('isClickOK' in confirmOption)? confirmOption.isClickOK : defaultOption.isClickOK;

    return this.newPromiseWithCheckReady((resolve, reject)=>{
      this.browserPage.waitForConfirm(action, message, isClickOK)
      .then(resolve).catch(reject);
    })
  }
  /**
   * @param {object} alertOptioin - specify message property(default undefined)
   * @param {function} action - A function representing the operation of open alert dialog
   * @return {Promise}
   * @example
   *   await page.waitAlertDialog({ message: "You are wrong!" }, ()=>{
   *     page.find("button.submit-form").click()
   *   })
   */
  waitForAlert(alertOptioin, action){
    const defaultOption = { message: "" };
    if(typeof action == "undefined" && typeof alertOptioin == "function"){
      action = alertOptioin
      alertOptioin = defaultOption;
    }

    return this.newPromiseWithCheckReady((resolve, reject)=>{
      this.browserPage.waitForAlert(action, alertOptioin.message)
      .then(resolve).catch(reject);
    })
  }
  /**
   * if window.isReadyForPage is false, return failed promise.
   * if window.isReadyForPage is true, invoke given "func".
   *   And "resolve" which is passed to "func" is resolved, "action" will be called.
   * @private
   */
  newPromiseWithCheckReady(func, action){
    let promise1 = new Promise((resolve, reject)=>{
      if(!this.window.isReadyForPage()){
        return reject("PageContent is not ready!, Please call the 'load' method first");
      }
      try{
        return (func && func(resolve, reject));
      }catch(err){
        return reject(err)
      }
    });

    let promise2 = new Promise((resolve, reject)=>{
      try {
        let result = action && action();
        if(result instanceof Promise){
          result.then(resolve).catch(reject)
        }else{
          resolve(result);
        }
      }catch(e){
        reject(e);
      }
    });
    return Promise.all([promise1, action && promise2]).then(values => values[0]);
  }
  /**
   * @param {string} code - JavaScript Code for executing this page.
   * @return {Promise}
   */
  executeJs(code){
    return this.newPromiseWithCheckReady((resolve, reject)=>{
      return this.browserPage.executeJavaScript(code, true).then(resolve).catch(reject);
    })
  }
  /**
   * Create and Return new Finder object with given finder-condition
   * @override
   * @return {Finder}
   */
  find(...args){
    let finder = new (this.browserPage.FinderClass)(this, ...args)
    return this.context ? finder.withContext(this.context) : finder;
  }
  /**
   * Create and Return new Page object in given iframe context.
   * @param {Finder} finderForIframe - finder object for iframe
   * @return {Page}
   * @example
   *   const pageInIframe = page.inIframe(page.find("iframe").first());
   *   await pageInIframe.find("h3").text()
   */
  inIframe(finderForIframe){
    return new Page(this._browserPage, finderForIframe.toContextString());
  }
  /**
   * Get the HTML source of current page.
   * @return {Promise} resolved by current HTML source.
   */
  getSourceHTML(){
    return this.executeJs(`document.getElementsByTagName("html")[0].outerHTML`);
  }
  openDevTools(){
    return this._browserPage.openDevTools();
  }
  /**
   * Take a screenshot of this page.
   * @param {String} fileName - fileName for saving as it in config.screentshotsDir. If absolutePath is given, save to it.
   * @return {Promise} resolved when taken, the value is absolute filePath
   */
  takeScreenshot(fileName){
    if(!fileName){
      throw new Error('fileName is missing!')
    }
    if(!path.isAbsolute(fileName)){
      fileName = path.resolve(path.join(config.screenshotsDir, fileName));
    }
    return this.browserPage.takeScreenshot(fileName);
  }
  /**
   * @return {Assert} Assert Object
   */
  assertScreen(identifier, options){
    this._assert = this._assert || new Assert(this, config)
    return this._assert.equal(identifier, options)
  }

  /**
   * Download file that url given.
   * This method operates outside of browser.
   */
  wget(url){
    url = new URL(url);
    const http = require(url.protocol.replace(/\:/, ''));

    return new Promise((resolve, reject)=>{
      http.get(url.toString(),  (res) => {
        if (res.statusCode != 200) {
          return reject(new Error(`ERROR: download response with status code: ${res.statusCode}`))
        }

        let contentDisposition = res.headers["content-disposition"];
        let filename = "unknown_file";
        if (contentDisposition) {
          const matched1 = contentDisposition.match(/filename\*=utf8''(.*)$/);
          const matched2 = contentDisposition.match(/filename=\"(.*)\"$/);
          if (matched1 || matched2) {
            filename = matched1 ? matched1[1] : matched2[1];
          }
        }
        const saveFilename = `${DateFormat.toTimepstampMsecString(new Date())}_${filename}`;
        const result = {
          filename: filename,
          savedFilePath: path.resolve(path.join(config.fileDownloadDir, saveFilename))
        };

        let fWriteStream = fs.createWriteStream(result.savedFilePath)
        res.pipe(fWriteStream)

        fWriteStream.on('close', ()=>{
          resolve(result);
        });
      })
      .on('error', (err) => {
        reject(new Error(`Failure on downloading url [${url.toString()}], reason: ${err}`))
      });
    })
  }
}

mixin(Page.prototype, PageMixin);

module.exports = Page;