Home Reference Source

lib/behaviorRunner.js

import { autobind } from './general';
import { doneOrWait } from './postStepFNs';
import { createMouseEvent } from './events';
import { delay } from './delays';

/**
 * A thin wrapper around the execution of a behaviors actions
 * in order to support behavior pausing.
 *
 * A behavior is in a paused state when the property `$WBBehaviorPaused`,
 * found on the `window` object, is truthy and when that property is
 * falsy the behavior is considered in an un-paused state.
 *
 * The check for a behavior pause state transitions is done
 * before performing the behaviors next action, allowing the
 * action to be atomic.
 */
export class BehaviorRunner {
  /**
   * @param {BehaviorRunnerOpts} init
   */
  constructor({ behaviorStepIterator, postStepFN, metadata }) {
    /**
     *  The behavior's action iterator
     * @type {RawBehaviorIterator}
     */
    this.stepIterator = behaviorStepIterator;

    /**
     * @type {?number}
     */
    this.upCheckInterval = null;

    /**
     * @type {?Object}
     */
    this.metadata = metadata;

    /**
     * A function used to transform the return value of {@link BehaviorRunner#stepIterator}
     * after performing an action into a value that is interpretable by Webrecorders automation
     * system.
     * @type {?function(result: RawBehaviorStepResults): BehaviorStepResults}
     */
    this.postStepFN = postStepFN;

    /**
     * @type {?Promise<void>}
     * @private
     */
    this._waitP = null;
    autobind(this);
  }

  /**
   * Returns T/F indicating if the behavior is currently paused
   * @return {boolean}
   */
  get isPaused() {
    return window.$WBBehaviorPaused;
  }

  /**
   * Swaps the behavior actions to be applied to the page.
   * @param {RawBehaviorIterator} newBehaviorIterator - a new behavior action iterator to be run
   * @param {function(results: RawBehaviorStepResults): BehaviorStepResults} [newPostStepFN] - an optional new post step function
   * to be used. If a previous postStepFN is in use and a new function is not supplied the old one is persisted.
   */
  swapBehaviorIterator(newBehaviorIterator, newPostStepFN) {
    this.stepIterator = newBehaviorIterator;
    if (newPostStepFN) {
      this.postStepFN = newPostStepFN;
    }
  }

  /**
   * Swaps the postStepFN to be used.
   * @param {function(results: RawBehaviorStepResults): BehaviorStepResults} newPostStepFN - The new
   * postStepFN to be used.
   */
  swapPostStepFn(newPostStepFN) {
    this.postStepFN = newPostStepFN;
  }

  /**
   * Returns a promise that resolves once `window.$WBBehaviorPaused`
   * is falsy, checking at 2 second intervals.
   * @return {Promise<void>}
   */
  waitToBeUnpaused() {
    if (this._waitP) return this._waitP;
    this._waitP = new Promise(resolve => {
      this.upCheckInterval = setInterval(() => {
        if (!window.$WBBehaviorPaused) {
          clearInterval(this.upCheckInterval);
          this._waitP = null;
          resolve();
        }
      }, 2000);
    });
    return this._waitP;
  }

  /**
   * Calls the next function of {@link BehaviorRunner#stepIterator} and
   * if a postStepFN was supplied it is called with the results of
   * the performed action otherwise they are returned directly.
   * @return {Promise<BehaviorStepResults>}
   */
  performStep() {
    const resultP = this.stepIterator.next();
    if (this.postStepFN) {
      return resultP.then(this.postStepFN);
    }
    return resultP.then(doneOrWait);
  }

  /**
   * Initiates the next action of a behavior.
   *
   * If the behavior is transitioning into the paused state (previously not paused)
   * the promise returned resolves with the results of performing the next
   * action once the un-paused state has been reached.
   *
   * If this method is called and the behavior is currently in the paused state
   * the promise returned is the same one returned when transitioning into the
   * paused state.
   *
   * Otherwise the returned promise resolves with the state of the behavior
   * after performing an action.
   * @return {Promise<BehaviorStepResults>}
   */
  step() {
    if (this._waitP) {
      return this._waitP;
    }
    if (window.$WBBehaviorPaused) {
      return this.waitToBeUnpaused().then(this.performStep);
    }
    return this.performStep();
  }

  /**
   * Shortcut for automatically run the behavior via async generators
   * @example
   *   // in some async function
   *   for await (const value of runner.autoRunIter()) {
   *     console.log(value)
   *   }
   * @param {number} [delayAmount] - Optional amount of delay to be applied between
   * steps
   * @return {AsyncIterableIterator<BehaviorStepResults>}
   */
  async *autoRunIter(delayAmount) {
    const haveDelay = typeof delayAmount === 'number';
    window.$WBNOOUTLINKS = true;
    let next = await this.step();
    while (!next.done) {
      yield next;
      if (haveDelay) await delay(delayAmount);
      next = await this.step();
    }
    yield next;
  }

  /**
   * Automatically run the behavior
   * @param {BehaviorRunOptions} [options = {}]
   * @return {Promise<void>}
   */
  async autoRun(options = {}) {
    const { delayAmount, noOutlinks = true, logging = true } = options;
    const haveDelay = typeof delayAmount === 'number';
    window.$WBNOOUTLINKS = noOutlinks;
    let next = await this.step();
    while (!next.done) {
      if (logging) console.log(next.msg, next.state);
      if (haveDelay) await delay(delayAmount);
      next = await this.step();
    }
    if (logging) console.log('done');
  }

  /**
   * Automatically run the behavior to completion optionally supplying
   * an amount of time, in milliseconds, that will be waited for
   * before initiating another action
   * the wait after performing an behavior action (step)
   * @param {BehaviorRunOptions} [options = {}]
   * @return {Promise<void>}
   */
  async autoRunWithDelay(options = {}) {
    const { delayAmount = 1000, noOutlinks = true, logging = true } = options;
    window.$WBNOOUTLINKS = noOutlinks;
    let next = await this.step();
    while (!next.done) {
      if (logging) console.log(next.msg, next.state);
      await delay(delayAmount);
      next = await this.step();
    }
    if (logging) console.log('done');
  }

  /**
   * Pauses the behavior by setting the behavior paused flag to true
   */
  pause() {
    window.$WBBehaviorPaused = true;
  }

  /**
   * Un-pauses the behavior by setting the behavior paused flag to false
   */
  unpause() {
    window.$WBBehaviorPaused = false;
  }

  /**
   * Shortcut for running a behavior from a for await of loop.
   * @return {BehaviorRunnerAsyncIterator}
   */
  [Symbol.asyncIterator]() {
    return {
      next: () => this.step(),
    };
  }
}

/**
 * Performs the setup required for integration with Webrecorders automation system
 * Adds as propeties to the supplied window object
 *  - $WBBehaviorStepIter$: the supplied `behaviorStepIterator`
 *  - $WBBehaviorRunner$: the instance of the class that wraps the behaviors actions
 *  - $WRIteratorHandler$: a function that initiates a behaviors action
 * @param {BehaviorRunnerInitOpts} init - Object used to initialize the behavior for running
 * @return {BehaviorRunner}
 */
export function initRunnableBehavior({
  win,
  behaviorStepIterator,
  postStepFN,
  metadata,
}) {
  win.$WBBehaviorStepIter$ = behaviorStepIterator;
  const runner = new BehaviorRunner({
    behaviorStepIterator,
    postStepFN,
    metadata,
  });
  win.$WBBehaviorRunner$ = runner;
  win.$WRIteratorHandler$ = runner.step;
  const crect = win.document.documentElement.getBoundingClientRect();
  const x = Math.floor(Math.random() * crect.right - 100);
  const y = Math.floor(Math.random() * crect.bottom - 100);
  win.document.dispatchEvent(
    createMouseEvent({
      type: 'mousemove',
      position: {
        pageX: x,
        pageY: y,
        clientX: x,
        clientY: y,
        screenX: Math.floor(Math.random() * win.screen.width),
        screenY: Math.floor(Math.random() * win.screen.height),
      },
    })
  );
  return runner;
}

/**
 * @typedef {Object} BehaviorRunnerOpts
 * @property {RawBehaviorIterator} behaviorStepIterator - An async iterator that
 * yields the state of the behavior
 * @property {function(results: RawBehaviorStepResults): BehaviorStepResults} [postStepFN] - An optional
 * function that is supplied the state of the behavior after performing an action.
 * It is required that this function returns the state unmodified or a transformation
 * of the state that is either an object that contains the original value of the done property
 * or a boolean that is the value of the original done property.
 * @property {Object} [metadata] - Optional metadata about the behavior currently being run
 */

/**
 * @typedef {Object} BehaviorRunnerInitOpts
 * @property {Window} win
 * @property {RawBehaviorIterator} behaviorStepIterator - An async iterator that
 * yields the state of the behavior
 * @property {function(results: RawBehaviorStepResults): BehaviorStepResults} [postStepFN] - An optional
 * function that is supplied the state of the behavior after performing an action.
 * It is required that this function returns the state unmodified or a transformation
 * of the state that is either an object that contains the original value of the done property
 * or a boolean that is the value of the original done property.
 * @property {Object} [metadata] - Optional metadata about the behavior currently being run
 */

/**
 * @typedef {Object} BehaviorRunnerAsyncIterator
 * @property {function(): Promise<BehaviorStepResults>} next - function used to initiate the next action of the behavior
 */

/**
 * @typedef {Object} BehaviorRunOptions
 * @property {number} [delayAmount = 1000] - Time value in milliseconds representing how much time should be waited after initiating a behavior's step
 * @property {boolean} [noOutlinks = true] - Should the collection of outlinks be disabled when running the behavior
 * @property {boolean} [logging = true] - Should information the behavior sends be displayed
 */

/**
 * @typedef {AsyncIterator<RawBehaviorStepResults>|AsyncIterableIterator<RawBehaviorStepResults>} RawBehaviorIterator
 */