Source: harness.js

const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const jsonToXml = require('pojo2xml');
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 = require('./toDate');

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('medic-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 {HarnessInputs} [options.user=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {HarnessInputs} [options.subject=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {HarnessInputs} [options.content=harness configuration file] The default {@link HarnessInputs} controlling the environment in which your application is running
   * @param {HarnessInputs} [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,
      _.pick(fileBasedDefaults, 'useDevMode', 'coreVersion'),
      {
        subject: 'default_subject',
        user: 'default_user',
        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);
      }
    });

    await this.page.goto(`file://${pathToHost}`);
    await this.page.waitForSelector('#task-report');

    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);
    return this.page && await this.page.evaluate(() => delete window.now);
  }

  /**
   * 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 {HarnessInputs} [options=Default values specified via constructor] 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 medic-conf-test-harness.loadForm() before calling start()`);
    }

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

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

    await doLoadForm(this, this.page, xformFilePath, content, user, serializedContactSummary);
    this._state.pageContent = await this.page.content();
    return this._state;
  }

  /**
   * 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) {
    const xformFilePath = path.resolve(this.options.contactXFormFolderPath, `${contactType}-create.xml`);

    const user = await resolveMock(this.coreAdapter, this.state, this.options.user);
    await doLoadForm(this, this.page, xformFilePath, {}, user);
    this._state.pageContent = await this.page.content();

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

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

    if (this.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));
    }

    this.pushMockedDoc(...fillResult.contacts);
    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|number|string} now A Date object or a value which can be parsed into a Date
   */
  setNow(now) {
    if (!now) {
      throw Error('undefined date passed to setNow');
    }

    const parseableNow = typeof now === 'object' ? now.getTime() : now;
    const asTimestamp = toDate(parseableNow).getTime();
    this._now = asTimestamp;
    sinon.useFakeTimers(asTimestamp);
    return this.page && this.page.evaluate(innerNow => window.now = new Date(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|number} amount Either an object describing how far to move forward in time. Has attributes { years, days, hours, minutes, seconds, ms }. Or an 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();
    if (typeof amount === 'object') {
      const { years = 0, days = 0, hours = 0, minutes = 0, seconds = 0, ms = 0 } = amount;
      now = now +
        years   * 1000 * 60 * 60 * 24 * 365 +
        days    * 1000 * 60 * 60 * 24 +
        hours   * 1000 * 60 * 60 +
        minutes * 1000 * 60 +
        seconds * 1000 +
        ms;
    } else { // shorthand is for days
      now += amount * 24 * 60 * 60 * 1000;
    }
    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();
    }

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

    if (this.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));
    }

    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 {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,
      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, 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 {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).
   *
   * @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,
    });

    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, 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() !== 'Failed to load resource: net::ERR_UNKNOWN_URL_SCHEME');
  }

  /**
   * `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; }

  /**
   * `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; }

  /**
   * @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 reportHasMatchingSubject = report => self.core.RegistrationUtils.getSubjectId(report) === resolvedContact._id;
    const resolvedReports = Array.isArray(reports) ? [...reports] : self._state.reports.filter(reportHasMatchingSubject);
    
    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));
    }
    
    if (this.options.useDevMode) {
      return devMode.runContactSummary(this.options.appSettingsPath, resolvedContact, resolvedReports, resolvedLineage);
    } else {
      const contactSummaryFunction = new Function('contact', 'reports', 'lineage', self.appSettings.contact_summary);
      return contactSummaryFunction(resolvedContact, resolvedReports, resolvedLineage);
    }
  }
}

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, xformFilePath, content, user, contactSummaryXml) => {
  self.log(`Loading form ${path.basename(xformFilePath)}...`);
  const xform = readFileSync(xformFilePath);
  if (!xform) {
    throw Error(`XForm not available at path: ${xformFilePath}`);
  }
  self.onConsole = msg => self._state.console.push(msg);

  const formNameWithoutDirectory = path.basename(xformFilePath, '.xml');
  const loadXformWrapper = (innerFormName, innerForm, innerContent, innerUser, innerContactSummary) => window.loadXform(innerFormName, innerForm, innerContent, innerUser, innerContactSummary);
  await page.evaluate(loadXformWrapper, formNameWithoutDirectory, xform, content, user, contactSummaryXml);
};

const serializeContactSummary = (contactSummary = {}) => {
  if (typeof contactSummary !== 'object') {
    throw Error('Invalid contactSummary. Object is expected');
  }

  if (contactSummary.xmlStr) {
    return contactSummary;
  }

  const serialize = cs => ({ id: 'contact-summary', xmlStr: jsonToXml(cs) });
  if (contactSummary.context) {
    return serialize({ context: contactSummary.context });
  }

  return serialize({ context: contactSummary });
};

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;