Source: harness.js

const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const { DateTime } = require('luxon');
const process = require('process');
const PuppeteerChromiumResolver = require('puppeteer-chromium-resolver');
const sinon = require('sinon');
const uuid = require('uuid/v4');

const devMode = require('./dev-mode');
const coreAdapter = require('./core-adapter');
const ChtCoreFactory = require('./cht-core-factory');
const { toDate, toDuration } = require('./dateUtils');

const pathToHost = path.join(__dirname, 'form-host/form-host.html');
if (!fs.existsSync(pathToHost)) {
  console.error(`File does not exist at ${pathToHost}`);
}

/**
 * A harness for testing MedicMobile WebApp configurations
 *
 * @example
 * const Harness = require('cht-conf-test-harness');
 * const instance = new Harness({
 *   verbose: true,
 *   directory: '/home/me/config-me/',
 * });
 * await instance.start();
 * await instance.setNow('2000-01-01');
 * await instance.loadForm('my_xform');
 * const result = await instance.fillForm(['first page, first answer', 'first page, second answer'], ['second page, first answer']);
 * expect(result.errors).to.be.empty;
 * expect(result.report).to.deep.include({
 *  fields: {
 *    patient_name: 'Patient Name',
 *    next_pnc: {
 *      s_next_pnc: 'no',
 *      next_pnc_date: '',
 *    },
 *  }
 * });
 */
class Harness {
  /**
   *
   * @param {Object=} options Specify the behavior of the Harness
   * @param {boolean} [options.verbose=false] Detailed console logging when true
   * @param {boolean} [options.logFormErrors=false] Errors displayed by forms will be logged via console when true
   * @param {string} [options.directory=process' working directory] Path to directory of configuration files being tested
   * @param {string} [options.xformFolderPath=path.join(options.directory, 'forms')] Path to directory containing xform files
   * @param {string} [options.appXFormFolderPath=path.join(options.xformFolderPath, 'app')] Path used by the loadForm interface
   * @param {string} [options.contactXFormFolderPath=path.join(options.xformFolderPath, 'contact')] Path used by the fillContactForm interface
   * @param {string} [options.appSettingsPath=path.join(options.directory, 'app_settings.json')] Path to file containing app_settings.json to test
   * @param {string} [options.harnessDataPath=path.join(options.directory, 'harness.defaults.json')] Path to harness configuration file
   * @param {string} [options.coreVersion=harness configuration file] The version of cht-core to emulate @example "3.8.0"
   * @param {string} [options.user=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {string} [options.userRoles=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {string} [options.subject=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {Object} [options.content=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {Object} [options.contactSummary=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {boolean} [options.headless=true] The options object is also passed into Puppeteer and can be used to control [any of its options]{@link https://github.com/GoogleChrome/puppeteer/blob/v1.18.1/docs/api.md#puppeteerlaunchoptions}
   * @param {boolean} [options.slowMo=false] The options object is also passed into Puppeteer and can be used to control [any of its options]{@link https://github.com/GoogleChrome/puppeteer/blob/v1.18.1/docs/api.md#puppeteerlaunchoptions}
   */
  constructor(options = {}) {
    const defaultDirectory = options.directory || process.cwd();
    this.options = _.defaults(options, {
      verbose: false,
      logFormErrors: true,
      xformFolderPath: path.join(defaultDirectory, 'forms'),
      appSettingsPath: path.join(defaultDirectory, './app_settings.json'),
      harnessDataPath: path.join(defaultDirectory, './harness.defaults.json'),
    });
    this.options = _.defaults(options, {
      appXFormFolderPath: path.join(this.options.xformFolderPath, 'app'),
      contactXFormFolderPath: path.join(this.options.xformFolderPath, 'contact'),
    });

    this.log = (...args) => this.options.verbose && console.log('Harness', ...args);

    const fileBasedDefaults = loadJsonFromFile(this.options.harnessDataPath);
    this.defaultInputs = _.defaults(
      this.options,
      fileBasedDefaults,
      {
        subject: 'default_subject',
        user: 'default_user',
        userRoles: ['default_role'],
        content: { source: 'action' },
        docs: [
          { _id: 'default_user', type: 'contact' },
          { _id: 'default_subject', type: 'contact' },
        ],
        ownedBySubject: false,
        actionForm: undefined,
      }
    );

    if (process.argv.includes('--dev')) {
      this.options.useDevMode = true;
    }

    const { availableCoreVersions } = ChtCoreFactory;
    this.options = _.defaults(
      this.options,
      _.pick(fileBasedDefaults, 'coreVersion'),
      { coreVersion: availableCoreVersions[availableCoreVersions.length - 1] },
    );

    this.core = ChtCoreFactory.get(this.options.coreVersion);
    this.appSettings = loadJsonFromFile(this.options.appSettingsPath);
    if (!this.appSettings) {
      throw Error(`Failed to load app settings expected at: ${this.options.appSettingsPath}`);
    }

    if (this.options.useDevMode) {
      devMode.mockRulesEngine(this.core, this.options.appSettingsPath);
    }

    clearSync(this);
  }

  /**
   * Starts a virtual browser. Typically put this in your test's before [hook]{@link https://mochajs.org/#hooks} or alike.
   * @returns {Promise.Browser} Resolves a [Puppeteer Browser]{@link https://github.com/GoogleChrome/puppeteer/blob/v1.18.1/docs/api.md#class-browser} when the harness is ready.
   *
   * @example
   * before(async () => { return await harness.start(); });
   */
  async start() {
    const chromiumResolver = await PuppeteerChromiumResolver({ silent: !this.options.verbose });
    this.options.executablePath = chromiumResolver.executablePath;
    this.browser = await chromiumResolver.puppeteer.launch(this.options);
    this.page = await this.browser.newPage();
    this.page.on('console', msg => {
      this.log(msg.type(), msg.text());

      if (typeof this.onConsole === 'function') {
        this.onConsole(msg);
      }
    });

    const formHostVersion = this.core.version.replace('.', '-');
    await this.page.goto(`file://${pathToHost}?core=${formHostVersion}`);
    await this.page.waitForSelector('#enketo-wrapper');

    if (this._now) {
      await this.setNow(this._now);
    }

    return this.browser;
  }

  /**
   * Stops and cleans up the virtual browser.
   * @returns {Promise} Resolves when the harness is fully cleaned up.
   * @example
   * after(async () => { return await harness.stop(); });
   */
  async stop() {
    this.log('Closing harness');
    sinon.restore();
    return this.browser && this.browser.close();
  }

  /**
   * Resets the {@link HarnessState}
   * @returns {Promise} Resolves when the state of the harness when cleared
   */
  async clear() {
    clearSync(this);
    if(!this.page) {
      return Promise.resolve();
    }

    // Clear any WebMediaPlayers created by countdown-widget
    // https://github.com/medic/cht-conf-test-harness/issues/185
    await this.page.reload();
    return await this.page.evaluate(() => window.restoreTimers());
  }

  /**
   * Load a form from the app folder into the harness for testing
   *
   * @param {string} formName Filename of an Xml file describing an XForm to load for testing
   * @param {string} [options.user] You can override some or all of the {@link HarnessInputs} attributes.
   * @param {string} [options.subject=harness configuration file] You can override some or all of the {@link HarnessInputs} attributes.
   * @param {Object} [options.content=harness configuration file] You can override some or all of the {@link HarnessInputs} attributes.
   * @param {Object} [options.contactSummary=harness configuration file] You can override some or all of the {@link HarnessInputs} attributes.
   * @returns {HarnessState} The current state of the form
   * @deprecated Use fillForm interface (#40)
   */
  async loadForm(formName, options = {}) {
    if (!this.page) {
      throw Error(`loadForm(): Cannot invoke cht-conf-test-harness.loadForm() before calling start()`);
    }

    options = _.defaults(options, {
      subject: this.options.subject,
      content: this.options.content,
      user: this.options.user,
      userSettingsDoc: this.userSettingsDoc,
    });

    const xformFilePath = path.resolve(this.options.appXFormFolderPath, `${formName}.xml`);
    const content = await resolveContent(this.coreAdapter, this.state, options.content, options.subject);
    const contactSummary = options.contactSummary || await this.getContactSummary(content.contact);

    const formNameWithoutDirectory = path.basename(xformFilePath, '.xml');
    await doLoadForm(this, this.page, this.core, formNameWithoutDirectory, 'app', xformFilePath, content, options.userSettingsDoc, contactSummary);
    this._state.pageContent = await this.page.content();
    return this._state;
  }

  /**
   * @deprecated since version 2.4.1, use {@link fillContactCreateForm} instead
   * 
   * Loads and fills a contact form,
   *
   * @param {string} contactType Type of contact that should be created
   * @param  {...string[]} answers Provide an array for the answers given on each page. See fillForm for more details.
   */
  async fillContactForm(contactType, ...answers) {
    return this.fillContactCreateForm(contactType, ...answers);
  }

  /**
 * Loads and fills a contact form
 *
 * @param {string} contactType Type of contact that should be created
 * @param  {...string[]} answers Provide an array for the answers given on each page. See fillForm for more details.
 */
  async fillContactCreateForm(contactType, ...answers) {
    const fillResult = await fillContactForm(this, contactType, 'create', ...answers);
    this.pushMockedDoc(...fillResult.contacts);
    return fillResult;
  }

  /**
   * Loads and fills a contact edit form
   *
   * @param {string} contactType Type of contact that should be created
   * @param  {...string[]} answers Provide an array for the answers given on each page. See fillForm for more details.
   */
  async fillContactEditForm(contactType, ...answers) {
    const fillResult = await fillContactForm(this, contactType, 'edit', ...answers);
    const keepValueIfEmpty = (objValue, srcValue, key) => {
      return key === '_id' || _.isEmpty(srcValue) ? objValue : srcValue;
    };
    _.assignInWith(this.subject, fillResult.contacts[0], keepValueIfEmpty);
    return fillResult;
  }

  /**
   * Set the current mock-time of the harness. Mocks global time {@link https://sinonjs.org/releases/v1.17.6/fake-timers/|uses sinon}
   * @param {Date|DateTime|number|string} now A Date object, {@link https://moment.github.io/luxon/docs/class/src/datetime.js~DateTime.html|DateTime object} or a value which can be parsed into a Date
   */
  setNow(now) {
    if (!now) {
      throw Error('undefined date passed to setNow');
    }
    const asTimestamp = toDate(now).toMillis();
    this._now = asTimestamp;
    sinon.useFakeTimers(asTimestamp);
    return this.page && this.page.evaluate(innerNow => window.fakeTimers(innerNow), this._now);
  }

  /**
   * Get the current mock-time. If no time has been set, defaults to the current system time.
   * @returns {number} The current mock-time as epoch time (set via `setNow` or `flush`). If time has not been mocked, defaults to the current system clock.
   */
  getNow() {
    return this._now || Date.now();
  }

  /**
   * Increment the current time by an amount
   * @param {Object|Duration|number} amount An object with attributes { years, quarters, months, weeks, days, hours, minutes, seconds, milliseconds } describing how far to move forward in time, a {@link https://moment.github.io/luxon/docs/class/src/duration.js~Duration.html|Duration object} or a number describing how many days to move forward in time.
   * @example
   * await flush({ years: 1, minutes: 5 }); // move one year and 5 minutes forward in time
   * await flush(1); // move one day forward in time
   */
  async flush(amount) {
    let now = this._now || Date.now();
    now = toDate(now).plus(toDuration(amount));
    return this.setNow(now);
  }

  /**
   * Fills in a form given some answers
   * @param {Object|string} load An optional shorthand for loading a form before filling it. If a string is provided, this will be passed to {@link loadForm} as the formName.
   * If an Object is provided, it can have attributes { form, user, content, contactSummary }
   * @param {...string[]} answers Provide an array for the answers given on each page
   * @returns {FillResult} The result of filling the form
   * @example
   * // Load a form and then fill it in
   * await harness.loadForm('my_form');
   * const result = await harness.fillForm(['first page first answer', 'first page second answer'], ['second page first answer']);
   *
   * // Load and fill a form in one statement
   * const result = await harness.fillForm('my_form', ['1', '2'], ['3']});
   */
  async fillForm(...answers) {
    const [firstParam] = answers;
    if (!Array.isArray(firstParam)) {
      if (typeof firstParam === 'object') {
        const options = _.defaults(firstParam, this.options);
        await this.loadForm(firstParam.form, options);
      } else {
        await this.loadForm(firstParam);
      }

      answers.shift();
    }

    const fillResult = await doFillPage(this.page, this.log, this.options, answers);
    if (fillResult.report) {
      fillResult.additionalDocs.forEach(doc => { doc._id = uuid(); });
      this.pushMockedDoc(fillResult.report, ...fillResult.additionalDocs);
    }

    return fillResult;
  }

  /**
   * Check which tasks are visible
   * @param {Object=} options Some options when checking for tasks
   * @param {string} [options.title=undefined] Filter the returns tasks to those with attribute `title` equal to this value. Filter is skipped if undefined.
   * @param {Object} [options.user=Default specified via constructor] The current logged-in user which is viewing the tasks.
   * @param {Object} [options.userRoles=Default specified via constructor] The roles associated with the current logged-in user which is viewing the tasks.
   * @param {string} [options.actionForm] Filter task documents to only those whose action opens the form equal to this parameter. Filter is skipped if undefined.
   * @param {boolean} [options.ownedBySubject] Filter task documents to only those owned by the subject. Filter is skipped if false.
   *
   * @returns {Task[]} An array of task documents which would be visible to the user given the current {@link HarnessState}
   */
  async getTasks(options) {
    options = _.defaults(options, {
      subject: this.options.subject,
      user: this.options.user,
      userRoles: this.options.userRoles,
      actionForm: this.options.actionForm,
      ownedBySubject: this.options.ownedBySubject,
      title: undefined,
    });

    if (options.resolved) {
      throw Error('getTasks({ resolved: true }) is not supported. See getTaskDocStates() to understand the state of tasks.');
    }

    if (options.now) {
      throw Error('getTasks({ now }) is not supported. See setNow() for mocking time.');
    }

    const user = await resolveMock(this.coreAdapter, this.state, options.user);
    const subject = await resolveMock(this.coreAdapter, this.state, options.subject, { hydrate: false });
    const tasks = await this.coreAdapter.fetchTasksFor(user, options.userRoles, stateEnsuringPresenceOfMocks(this.state, user, subject));

    tasks.forEach(task => task.emission.actions.forEach(action => {
      action.forId = task.emission.forId; // required to hydrate contact in loadAction()
    }));

    return filterTaskDocs(tasks, subject._id, options);
  }

  /**
   * Counts the number of task documents grouped by state. [Explanation of task documents and states]{@link https://docs.communityhealthtoolkit.org/core/overview/db-schema/#tasks}
   *
   * @param {Object=} options Some options when summarizing the tasks
   * @param {string} [options.title=undefined] Filter task documents counted to only those with emitted `title` equal to this parameter. Filter is skipped if undefined.
   * @param {Object} [options.user=Default specified via constructor] The current logged-in user which is viewing the tasks.
   * @param {Object} [options.userRoles=Default specified via constructor] The roles associated with the current logged-in user which is viewing the tasks.
   * @param {string} [options.actionForm] Filter task documents counted to only those whose action opens the form equal to this parameter. Filter is skipped if undefined.
   * @param {boolean} [options.ownedBySubject] Filter task documents counted to only those owned by the subject. Filter is skipped if false.
   *
   * @returns Map with keys equal to task document state and values equal to the number of task documents in that state.
   * @example
   * const summary = await countTaskDocsByState({ title: 'my-task-title' });
   * expect(summary).to.nested.include({
   *   Complete: 1, // 1 task events were marked as resolved
   *   Failed: 2,   // 2 task events were not marked as resolved prior to expiring
   *   Draft: 3,    // 3 task events are in the future
   * });
   *
   */
  async countTaskDocsByState(options) {
    options = _.defaults(options, {
      subject: this.options.subject,
      actionForm: this.options.actionForm,
      ownedBySubject: this.options.ownedBySubject,
      title: undefined,
    });

    await this.getTasks(options);

    const allTaskDocs = await this.coreAdapter.fetchTaskDocs();
    const subjectId = typeof this.subject === 'object' ? this.subject._id : this.subject;
    const relevantTaskDocs = filterTaskDocs(allTaskDocs, subjectId, options);
    const summary = {
      Draft: 0,
      Ready: 0,
      Cancelled: 0,
      Completed: 0,
      Failed: 0,
      Total: 0,
    };

    for (const task of relevantTaskDocs) {
      summary[task.state]++;
      summary.Total++;
    }
    return summary;
  }

  /**
   * Check the state of targets
   * @param {Object=} options Some options for looking for checking for targets
   * @param {string|string[]} [options.type=undefined] Filter the returns targets to those with an `id` which matches type (when string) or is included in type (when Array).
   * @param {Object} [options.user=Default specified via constructor] The current logged-in user which is viewing the tasks.
   * @param {Object} [options.userRoles=Default specified via constructor] The roles associated with the current logged-in user which is viewing the tasks.
   *
   * @returns {Target[]} An array of targets which would be visible to the user
   */
  async getTargets(options) {
    options = _.defaults(options, {
      type: undefined,
      subject: this.options.subject,
      user: this.options.user,
      userRoles: this.options.userRoles,
    });

    if (options.now) {
      throw Error('getTargets({ now }) is not supported. See setNow() for mocking time.');
    }

    const user = await resolveMock(this.coreAdapter, this.state, options.user);
    const subject = await resolveMock(this.coreAdapter, this.state, options.subject, { hydrate: false });
    const targets = await this.coreAdapter.fetchTargets(user, options.userRoles, stateEnsuringPresenceOfMocks(this.state, user, subject));

    return targets
      .filter(target =>
        !options.type ||
        (typeof options.type === 'string' && target.id === options.type) ||
        (Array.isArray(options.type) && options.type.includes(target.id))
      );
  }

  /**
   * Simulates the user clicking on an action
   * @param {Object} taskDoc A {@link Task} or, if that task has multiple actions then one of the direct actions
   * @example
   * // Complete a form on January 1
   * await harness.setNow('2000-01-01')
   * const initialResult = await harness.fillForm('pnc_followup', ['no'], ['yes', '2000-01-07']);
   * expect(initialResult.errors).to.be.empty;
   *
   * // Verify a task appears on January 7
   * await harness.setNow('2000-01-07');
   * const tasks = await harness.getTasks();
   * expect(tasks).to.have.property('length', 1);
   *
   * // Complete the task's action
   * await harness.loadAction(tasks[0]);
   * const followupResult = await harness.fillForm(['no_come_back']);
   * expect(followupResult.errors).to.be.empty;
   *
   * // Verify the task got resolved
   * const actual = await harness.getTasks();
   * expect(actual).to.be.empty;
   */
  async loadAction(taskDoc, ...answers) {
    if (typeof taskDoc !== 'object') {
      throw Error('invalid argument: "taskDoc"');
    }

    const getActionFromParam = () => {
      const isTaskDoc = !!taskDoc.emission;
      if (isTaskDoc) {
        const { actions } = taskDoc.emission;
        if (!Array.isArray(actions) || actions.length === 0) {
          throw Error(`loadAction: invalid argument "taskDoc" - has no actions to load`);
        }

        if (actions.length > 1) {
          throw Error('loadAction: invalid argument "taskDoc" - has multiple actions, so disambiguation is required. Directly pass the action to load: `loadAction(taskDoc.emission.actions[1]);`');
        }

        return actions[0];
      }

      return taskDoc; // assume it is an action
    };

    const action = getActionFromParam();
    // When an action is clicked after Rules-v2 the "emissions.content.contact" object is hydrated
    const subject = this.state.contacts.find(contact => action.forId && contact._id === action.forId);
    const content = Object.assign(
      {},
      action.content,
      { contact: subject || this.content.contact },
    );

    let result = await this.loadForm(action.form, { content });
    if (answers.length) {
      result = await this.fillForm(...answers);
    }
    return result;
  }

  /**
   * A filtered set of errors inside of state.console. Useful for easy assertions.
   * @example
   * expect(harness.consoleErrors).to.be.empty;
   */
  get consoleErrors() {
    return this._state.console
      .filter(msg => msg.type() !== 'log')
      .filter(msg => !msg.text().startsWith('Error submitting form data:'))
      .filter(msg => !msg.text().startsWith('Slow network is detected. See https://www.chromestatus.com/feature/5636954674692096 for more details. Fallback font will be used while loading:'))
      .filter(msg => msg.text() !== 'Failed to load resource: net::ERR_REQUEST_RANGE_NOT_SATISFIABLE') // BUG: #219
      .filter(msg => msg.text() !== 'Failed to load resource: net::ERR_UNKNOWN_URL_SCHEME') // BUG: #220
      .filter(msg => msg.text() !== 'Failed to load resource: net::ERR_FILE_NOT_FOUND') // BUG: #221
      .filter(msg => !msg.text().startsWith('Error fetching media file')) // BUG: #222
      .filter(msg => !msg.text().startsWith('Deprecation warning:')) // BUG: #223
      .filter(msg => !msg.text().includes('with null-based index')) // BUG: #224
    ;

  }

  /**
   * `user` from the {@link HarnessInputs} set through the constructor (defaulting to values from harness.defaults.json file)
   */
  get user() {
    const { user } = this.options;
    if (typeof user === 'string') {
      return this.state.contacts.find(contact => contact._id === user);
    }
    return user;
  }
  set user(value) { this.options.user = value; }

  /**
   * `userRoles` from the {@link HarnessInputs} set through the constructor (defaulting to values from harness.defaults.json file)
   */
  get userRoles() { return this.options.userRoles; }
  set userRoles(value) { this.options.userRoles = value; }

  /**
   * `coreVersion` is the version of the cht-core that is being emulated in testing (eg. 3.9.0)
   */
  get coreVersion() { return this.options.coreVersion; }

  /**
   * `content` from the {@link HarnessInputs} set through the constructor (defaulting to values from harness.defaults.json file)
   */
  get content() { return this.options.content; }
  set content(value) { this.options.content = value; }

  get subject() {
    const { subject } = this.options;
    if (typeof subject === 'string') {
      return this.state.contacts.find(contact => contact._id === subject);
    }
    return subject;
  }
  set subject(value) { this.options.subject = value; }


  /**
   * `userSettingsDoc` from the {@link HarnessInputs} set through the constructor
   * @default {Object} A constructed object of type `user-settings` https://docs.communityhealthtoolkit.org/core/overview/db-schema/#users based on
   * known user information
   */
  get userSettingsDoc() {
    if (this.options.userSettingsDoc) {
      return this.options.userSettingsDoc;
    }

    const user = this.user;
    if (!user) {
      return undefined;
    }

    return {
      _id: `org.couchdb.user:${user._id}`,
      name: user._id,
      type: 'user-settings',
      contact_id: user._id,
      facility_id: user.parent && user.parent._id,
    };
  }
  set userSettingsDoc(value) { this.options.userSettingsDoc = value; }

  /**
   * @typedef HarnessState
   * @property {Object[]} console Each element represents an event within Chrome console.
   * @property {Object[]} contacts All contacts known to nools.
   * @property {Object[]} reports All reports known to nools.
   */

  /**
   * Details the current {@link HarnessState} of the Harness
   * @returns {HarnessState} The current state of the harness
   */
  get state() { return this._state; }

  /**
   * Push a mocked document directly into the state
   * @param {Object} docs The document to push
   */
  pushMockedDoc(...docs) {
    const ContactTypes = ['contact', 'district_hospital', 'health_center', 'clinic', 'person'];

    for (const doc of docs) {
      if (Array.isArray(doc)) {
        this.pushMockedDoc(...doc);
        continue;
      }

      // Cht only stores minified contacts and reports - so harness defaults to do the same
      this.coreAdapter.minify(doc);

      const isContact = doc && ContactTypes.includes(doc.type);
      if (isContact) {
        this._state.contacts.push(doc);
      } else {
        const report = _.defaults(doc, {
          _id: uuid(),
          type: 'data_record',
          reported_date: 1,
          fields: {},
        });

        const reportSubjectId = this.core.RegistrationUtils.getSubjectId(report);
        if (!reportSubjectId && this.subject) {
          // Legacy behaviour from harness@1.x
          console.warn(`pushMockedDoc: report without subject id (patient_id, patient_uuid, place_id, etc). Setting default to "${this.subject._id}".`);
          report.patient_id = this.subject._id; // patient_uuid is not available at root level
        }

        this._state.reports.push(report);
      }
    }
  }

  /**
   * @typedef ContactSummary
   * As defined in the [guide to developing community health applications]{@link https://github.com/medic/medic-docs/blob/master/configuration/developing-community-health-applications.md#contact-summaries}
   * @property {Field[]} fields
   * @property {Card[]} cards
   * @property {Context} context
   */

  /**
   * Obtains the result of the running the contact-summary.js or the compiled contact-summary.templated.js scripts from the project folder
   * @param {string} [contact] The contact doc that will be passed into the contactSummary script. Given a {string}, a contact will be loaded and hydrated from {@link HarnessState}. If left empty, the subject will be used.
   * @param {Object[]} [reports] An array of reports associated with contact. If left empty, the contact's reports will be loaded from {@link HarnessState}.
   * @param {Object[]} [lineage] An array of the contact's hydrated ancestors. If left empty, the contact's ancestors will be used from {@link HarnessState}.
   * @returns {ContactSummary} The result of the contact summary under test.
   */
  async getContactSummary(contact, reports, lineage) {
    const self = this;
    const resolvedContact = await resolveMock(this.coreAdapter, this.state, contact || this.options.subject);
    if (typeof resolvedContact !== 'object') {
      throw `Harness: Cannot get summary for unknown or invalid contact.`;
    }

    const resolvedReports = self.coreAdapter.getReportsForContactSummary(resolvedContact, reports, resolvedContact._id, this.state);
    let resolvedLineage = [];
    if (Array.isArray(lineage)) {
      resolvedLineage.push(...lineage);
    } else {
      const user = await resolveMock(this.coreAdapter, this.state, this.options.user);
      const subject = await resolveMock(this.coreAdapter, this.state, this.options.subject);
      resolvedLineage = await this.coreAdapter.buildLineage(resolvedContact._id, stateEnsuringPresenceOfMocks(this.state, user, subject));
    }

    const chtScriptApi = this.coreAdapter.chtScriptApi(this.options.userRoles);
    if (this.options.useDevMode) {
      return devMode.runContactSummary(this.options.appSettingsPath, resolvedContact, resolvedReports, resolvedLineage, chtScriptApi);
    } else {
      const contactSummaryFunction = new Function('contact', 'reports', 'lineage', 'cht', self.appSettings.contact_summary);
      return contactSummaryFunction(resolvedContact, resolvedReports, resolvedLineage, chtScriptApi);
    }
  }
}

/**
 * Loads and fills a contact form with the appropriate action
 *
 * @param {string} contactType Type of contact that should be created
 * @param {string} action one of 'create' or 'edit'
 * @param  {...string[]} answers Provide an array for the answers given on each page. See fillForm for more details.
 */
const fillContactForm = async (self, contactType, action, ...answers) => {
  const xformFilePath = path.resolve(self.options.contactXFormFolderPath, `${contactType}-${action}.xml`);

  const user = await resolveMock(self.coreAdapter, self.state, self.options.user);
  await doLoadForm(self, self.page, self.core, contactType, 'contact', xformFilePath, {}, user);
  self._state.pageContent = await self.page.content();

  const fillResult = await doFillPage(self.page, self.log, self.options, answers);

  // https://github.com/medic/cht-conf-test-harness/issues/105
  if (self.subject && self.subject.parent) {
    fillResult.contacts.forEach(contact => {
      if (!contact.parent || !contact.parent._id) {
        contact.parent = self.subject.parent;
      }
    });
  }

  return fillResult;
};

const loadJsonFromFile = filePath => {
  const content = readFileSync(filePath);
  return content && JSON.parse(content);
};

const readFileSync = (...args) => {
  const filePath = path.join(...args);
  if (!fs.existsSync(filePath)) {
    return;
  }

  return fs.readFileSync(filePath).toString();
};

const doLoadForm = async (self, page, core, formName, formType, xformFilePath, content, userSettingsDoc, contactSummaryXml) => {
  await page.evaluate(() => window.unload && window.unload());

  self.log(`Loading form ${path.basename(xformFilePath)}...`);
  const formXmlContent = readFileSync(xformFilePath);
  if (!formXmlContent) {
    throw Error(`XForm not available at path: ${xformFilePath}`);
  }
  self.onConsole = msg => self._state.console.push(msg);

  const { form: formHtml, model: formModel } = await core.convertFormXmlToXFormModel(formXmlContent);
  const loadFormWrapper = (innerFormName, innerFormType, innerFormHtml, innerFormModel, innerFormXml, innerContent, innerUserSettingsDoc, innerContactSummary) =>
    window.loadForm(innerFormName, innerFormType, innerFormHtml, innerFormModel, innerFormXml, innerContent, innerUserSettingsDoc, innerContactSummary);

  await page.evaluate(loadFormWrapper, formName, formType, formHtml, formModel, formXmlContent, content, userSettingsDoc, contactSummaryXml);
};

const doFillPage = async (page, log, options, pages) => {
  const processedAnswers = pages.map(answers => answers.map(answer => {
    let result = answer;
    if (result instanceof Date) {
      result = DateTime.fromJSDate(result);
    }

    if (result && result.isLuxonDateTime) {
      result = result.toISODate();
    }

    return result;
  }));

  log(`Filling ${processedAnswers.length} pages with answer: ${JSON.stringify(processedAnswers)}`);
  const fillResult = await page.evaluate(async innerAnswer => await window.fillAndSave(innerAnswer), processedAnswers);
  log(`Result of fill is: ${JSON.stringify(fillResult, null, 2)}`);

  if (options.logFormErrors && fillResult.errors && fillResult.errors.length > 0) {
    /* this.log respects verbose option, use logFormErrors here */
    console.error(`Error encountered while filling form:`, JSON.stringify(fillResult.errors, null, 2));
  }

  return fillResult;
};

const clearSync = (self) => {
  const contacts = [];

  self.options = _.cloneDeep(self.defaultInputs);
  self.coreAdapter = new coreAdapter(self.core, self.appSettings);

  self._state = {
    console: [],
    contacts,
    reports: [],
  };
  self.onConsole = () => { };
  self._now = undefined;

  sinon.restore();
  self.pushMockedDoc(...self.options.docs);
};

const resolveContent = async (coreAdapter, state, content, contact) => {
  if (content && !content.contact) {
    const resolvedContact = await resolveMock(coreAdapter, state, contact);
    return { ...content, contact: resolvedContact };
  }

  return content;
};

const resolveMock = async (coreAdapter, state, mock, options = {}) => {
  options = _.defaults(options, { hydrate: true });
  if (typeof mock === 'string') {
    if (options.hydrate) {
      return coreAdapter.fetchHydratedDoc(mock, state);
    }

    return state.contacts.find(contact => contact._id === mock);
  }

  return mock;
};

const filterTaskDocs = (taskDocs, subjectId, { ownedBySubject, actionForm, title }) => taskDocs
  .filter(task => !ownedBySubject || task.owner === subjectId)
  .filter(task => !actionForm || task.emission.actions[0].form === actionForm)
  .filter(task => !title || task.emission.title === title);

const stateEnsuringPresenceOfMocks = (state, ...mocks) => {
  const stragglers = _.uniqBy(mocks.filter(mock => !state.contacts.some(contact => contact._id === mock._id), '_id'));
  return {
    contacts: [...state.contacts, ...stragglers],
    reports: state.reports,
  };

};

module.exports = Harness;