// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {ElementObject} from './element_object.js';
import {KeyModifiers} from './key_modifiers.js';
import {getCaller, pending, repeatUntil, sendTestMessage} from './test_util.js';
import {VolumeManagerCommonVolumeType} from './volume_manager_common_volume_type.js';

/**
 * When step by step tests are enabled, turns on automatic step() calls. Note
 * that if step() is defined at the time of this call, invoke it to start the
 * test auto-stepping ball rolling.
 */
window.autoStep = () => {
  window.autostep = window.autostep || false;
  if (!window.autostep) {
    window.autostep = true;
  }
  if (window.autostep && typeof window.step === 'function') {
    window.step();
  }
};

/**
 * This error type is thrown by executeJsInPreviewTagSwa_ if the script to
 * execute in the untrusted context produces an error.
 */
export class ExecuteScriptError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ExecuteScriptError';
  }
}

/**
 * Class to manipulate the window in the remote extension.
 */
export class RemoteCall {
  /**
   * @param {string} origin ID of the app to be manipulated.
   */
  constructor(origin) {
    this.origin_ = origin;

    /**
     * Tristate holding the cached result of isStepByStepEnabled_().
     * @type {?boolean}
     */
    this.cachedStepByStepEnabled_ = null;
  }

  /**
   * Checks whether step by step tests are enabled or not.
   * @private
   * @return {!Promise<boolean>}
   */
  async isStepByStepEnabled_() {
    if (this.cachedStepByStepEnabled_ === null) {
      this.cachedStepByStepEnabled_ = await new Promise((fulfill) => {
        chrome.commandLinePrivate.hasSwitch(
            'enable-file-manager-step-by-step-tests', fulfill);
      });
    }
    return this.cachedStepByStepEnabled_;
  }

  /**
   * Sends a test |message| to the test code running in the File Manager.
   * @param {!Object} message
   * @return {!Promise<*>} A promise which when fulfilled returns the
   *     result of executing test code with the given message.
   */
  sendMessage(message) {
    return new Promise((fulfill) => {
      chrome.runtime.sendMessage(this.origin_, message, {}, fulfill);
    });
  }

  /**
   * Calls a remote test util in the Files app's extension. See:
   * registerRemoteTestUtils in test_util_base.js.
   *
   * @param {string} func Function name.
   * @param {?string} appId App window Id or null for functions not requiring a
   *     window.
   * @param {?Array<*>=} args Array of arguments.
   * @param {function(*)=} opt_callback Callback handling the function's result.
   * @return {!Promise} Promise to be fulfilled with the result of the remote
   *     utility.
   */
  async callRemoteTestUtil(func, appId, args, opt_callback) {
    const stepByStep = await this.isStepByStepEnabled_();
    let finishCurrentStep;
    if (stepByStep) {
      while (window.currentStep) {
        await window.currentStep;
      }
      window.currentStep = new Promise(resolve => {
        finishCurrentStep = () => {
          window.currentStep = null;
          resolve();
        };
      });
      console.info('Executing: ' + func + ' on ' + appId + ' with args: ');
      console.info(args);
      if (window.autostep !== true) {
        await new Promise((onFulfilled) => {
          console.info('Type step() to continue...');
          /** @type {?function()} */
          window.step = function() {
            window.step = null;
            onFulfilled();
          };
        });
      } else {
        console.info('Auto calling step() ...');
      }
    }
    const response = await this.sendMessage({func, appId, args});

    if (stepByStep) {
      console.info('Returned value:');
      console.info(JSON.stringify(response));
      finishCurrentStep();
    }
    if (opt_callback) {
      opt_callback(response);
    }
    return response;
  }

  /**
   * Waits until a window having the given ID prefix appears.
   * @param {string} windowIdPrefix ID prefix of the requested window.
   * @return {!Promise<string>} promise Promise to be fulfilled with a found
   *     window's ID.
   */
  waitForWindow(windowIdPrefix) {
    const caller = getCaller();
    const windowIdRegex = new RegExp(windowIdPrefix);
    return repeatUntil(async () => {
      const windows = await this.callRemoteTestUtil('getWindows', null, []);
      for (const id in windows) {
        if (id.indexOf(windowIdPrefix) === 0 || windowIdRegex.test(id)) {
          return id;
        }
      }
      return pending(
          caller, 'Window with the prefix %s is not found.', windowIdPrefix);
    });
  }

  /**
   * Waits until the window turns to the given size.
   * @param {string} appId App window Id.
   * @param {number} width Requested width in pixels.
   * @param {number} height Requested height in pixels.
   */
  waitForWindowGeometry(appId, width, height) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const windows = await this.callRemoteTestUtil('getWindows', null, []);
      if (!windows[appId]) {
        return pending(caller, 'Window %s is not found.', appId);
      }
      if (windows[appId].outerWidth !== width ||
          windows[appId].outerHeight !== height) {
        return pending(
            caller, 'Expected window size is %j, but it is %j',
            {width: width, height: height}, windows[appId]);
      }
    });
  }

  /**
   * Waits for the specified element appearing in the DOM.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @return {Promise<ElementObject>} Promise to be fulfilled when the element
   *     appears.
   */
  waitForElement(appId, query) {
    return this.waitForElementStyles(appId, query, []);
  }

  /**
   * Waits for the specified element appearing in the DOM.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @param {!Array<string>} styleNames List of CSS property name to be
   *     obtained. NOTE: Causes element style re-calculation.
   * @return {Promise<ElementObject>} Promise to be fulfilled when the element
   *     appears.
   */
  waitForElementStyles(appId, query, styleNames) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const elements = await this.callRemoteTestUtil(
          'deepQueryAllElements', appId, [query, styleNames]);
      if (elements && elements.length > 0) {
        return /** @type {ElementObject} */ (elements[0]);
      }
      return pending(caller, 'Element %s is not found.', query);
    });
  }

  /**
   * Waits for a remote test function to return a specific result.
   *
   * @param {string} funcName Name of remote test function to be executed.
   * @param {?string} appId App window Id.
   * @param {function(Object):boolean|boolean|Object} expectedResult An value to
   *     be checked against the return value of |funcName| or a callback that
   *     receives the return value of |funcName| and returns true if the result
   *     is the expected value.
   * @param {?Array<*>=} args Arguments to be provided to |funcName| when
   *     executing it.
   * @return {Promise} Promise to be fulfilled when the |expectedResult| is
   *     returned from |funcName| execution.
   */
  waitFor(funcName, appId, expectedResult, args) {
    const caller = getCaller();
    args = args || [];
    return repeatUntil(async () => {
      const result = await this.callRemoteTestUtil(funcName, appId, args);
      if (typeof expectedResult === 'function' && expectedResult(result)) {
        return result;
      }
      if (expectedResult === result) {
        return result;
      }
      const msg = 'waitFor: Waiting for ' +
          `${funcName} to return ${expectedResult}, ` +
          `but got ${JSON.stringify(result)}.`;
      return pending(caller, msg);
    });
  }

  /**
   * Waits for the specified element leaving from the DOM.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @return {Promise} Promise to be fulfilled when the element is lost.
   */
  waitForElementLost(appId, query) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const elements =
          await this.callRemoteTestUtil('deepQueryAllElements', appId, [query]);
      if (elements.length > 0) {
        return pending(caller, 'Elements %j still exists.', elements);
      }
      return true;
    });
  }

  /**
   * Wait for the |query| to match |count| elements.
   *
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @param {number} count The expected element match count.
   * @return {Promise} Promise to be fulfilled on success.
   */
  waitForElementsCount(appId, query, count) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const expect = `Waiting for [${query}] to match ${count} elements`;
      const result =
          await this.callRemoteTestUtil('countElements', appId, [query, count]);
      return !result ? pending(caller, expect) : true;
    });
  }

  /**
   * Sends a fake key down event.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @param {string} key DOM UI Events Key value.
   * @param {boolean} ctrlKey Control key flag.
   * @param {boolean} shiftKey Shift key flag.
   * @param {boolean} altKey Alt key flag.
   * @return {Promise} Promise to be fulfilled or rejected depending on the
   *     result.
   */
  async fakeKeyDown(appId, query, key, ctrlKey, shiftKey, altKey) {
    const result = await this.callRemoteTestUtil(
        'fakeKeyDown', appId, [query, key, ctrlKey, shiftKey, altKey]);
    if (result) {
      return true;
    } else {
      throw new Error('Fail to fake key down.');
    }
  }

  /**
   * Sets the given input text on the element identified by the query.
   * @param {string} appId App window ID.
   * @param {string|!Array<string>} selector The query selector to locate
   *     the element
   * @param {string} text The text to be set on the element.
   */
  async inputText(appId, selector, text) {
    chrome.test.assertTrue(
        await this.callRemoteTestUtil('inputText', appId, [selector, text]));
  }

  /**
   * Gets file entries just under the volume.
   *
   * @param {VolumeManagerCommonVolumeType} volumeType Volume type.
   * @param {Array<string>} names File name list.
   * @return {Promise} Promise to be fulfilled with file entries or rejected
   *     depending on the result.
   */
  getFilesUnderVolume(volumeType, names) {
    return this.callRemoteTestUtil(
        'getFilesUnderVolume', null, [volumeType, names]);
  }

  /**
   * Waits for a single file.
   * @param {VolumeManagerCommonVolumeType} volumeType Volume type.
   * @param {string} name File name.
   * @return {!Promise} Promise to be fulfilled when the file had found.
   */
  waitForAFile(volumeType, name) {
    const caller = getCaller();
    return repeatUntil(async () => {
      if ((await this.getFilesUnderVolume(volumeType, [name])).length === 1) {
        return true;
      }
      return pending(caller, '"' + name + '" is not found.');
    });
  }

  /**
   * Shorthand for clicking an element.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @param {KeyModifiers=} opt_keyModifiers Object
   * @return {Promise} Promise to be fulfilled with the clicked element.
   */
  async waitAndClickElement(appId, query, opt_keyModifiers) {
    const element = await this.waitForElement(appId, query);
    const result = await this.callRemoteTestUtil(
        'fakeMouseClick', appId, [query, opt_keyModifiers]);
    chrome.test.assertTrue(result, 'mouse click failed.');
    return element;
  }

  /**
   * Shorthand for right-clicking an element.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to specify the element.
   *     If query is an array, |query[0]| specifies the first
   *     element(s), |query[1]| specifies elements inside the shadow DOM of
   *     the first element, and so on.
   * @param {KeyModifiers=} opt_keyModifiers Object
   * @return {Promise} Promise to be fulfilled with the clicked element.
   */
  async waitAndRightClick(appId, query, opt_keyModifiers) {
    const element = await this.waitForElement(appId, query);
    const result = await this.callRemoteTestUtil(
        'fakeMouseRightClick', appId, [query, opt_keyModifiers]);
    chrome.test.assertTrue(result, 'mouse right-click failed.');
    return element;
  }

  /**
   * Shorthand for focusing an element.
   * @param {string} appId App window Id.
   * @param {!Array<string>} query Query to specify the element to be focused.
   * @return {Promise} Promise to be fulfilled with the focused element.
   */
  async focus(appId, query) {
    const element = await this.waitForElement(appId, query);
    const result = await this.callRemoteTestUtil('focus', appId, query);
    chrome.test.assertTrue(result, 'focus failed.');
    return element;
  }

  /**
   * Simulate Click in the UI in the middle of the element.
   * @param {string} appId App window ID contains the element. NOTE: The click
   *     is
   * simulated on most recent window in the window system.
   * @param {string|!Array<string>} query Query to the element to be clicked.
   * @param {boolean} leftClick If true, simulate left click. Otherwise simulate
   *     right click.
   * @return {!Promise} A promise fulfilled after the click event.
   */
  async simulateUiClick(appId, query, leftClick = true) {
    const element = /* @type {!Object} */ (
        await this.waitForElementStyles(appId, query, ['display']));
    chrome.test.assertTrue(!!element, 'element for simulateUiClick not found');

    // Find the middle of the element.
    const x =
        Math.floor(element['renderedLeft'] + (element['renderedWidth'] / 2));
    const y =
        Math.floor(element['renderedTop'] + (element['renderedHeight'] / 2));

    return sendTestMessage(
        {appId, name: 'simulateClick', 'clickX': x, 'clickY': y, leftClick});
  }

  /**
   * Simulate Right Click in blank/empty space of the file list element.
   * @param{string} appId App window ID contains the element. NOTE: The click is
   * simulated on most recent window in the window system.
   * @return {!Promise} A promise fulfilled after the click event.
   */
  async rightClickFileListBlankSpace(appId) {
    await this.simulateUiClick(
        appId, '#file-list .spacer.signals-overscroll', false);
  }

  /**
   * Selects the option given by the index in the menu given by the type. This
   * only works in V2 version of the search.
   *
   * @param {string} appId The ID that identifies the files app.
   * @param {string} type The search option type (location, recency, type).
   * @param {number} index The index of the button.
   * @return {Promise<boolean>} A promise that resolves to true if click was
   *     successful and false otherwise.
   */
  selectSearchOption(appId, type, index) {
    return this.callRemoteTestUtil('fakeMouseClick', appId, [
      [
        'xf-search-options',
        `xf-select#${type}-selector`,
        `cr-action-menu cr-button:nth-of-type(${index})`,
      ],
    ]);
  }
}

/**
 * Class to manipulate the window in the remote extension.
 */
export class RemoteCallFilesApp extends RemoteCall {
  /**
   * Sends a test |message| to the test code running in the File Manager.
   * @param {!Object} message
   * @return {!Promise<*>}
   * @override
   */
  sendMessage(message) {
    const command = {
      name: 'callSwaTestMessageListener',
      appId: message.appId,
      data: JSON.stringify(message),
    };

    return new Promise((fulfill) => {
      chrome.test.sendMessage(JSON.stringify(command), (response) => {
        if (response === '"@undefined@"') {
          fulfill(undefined);
        } else {
          try {
            fulfill(response == '' ? true : JSON.parse(response));
          } catch (e) {
            console.error(`Failed to parse "${response}" due to ${e}`);
            fulfill(false);
          }
        }
      });
    });
  }

  /** @override */
  async waitForWindow(windowIdPrefix) {
    return this.waitForSwaWindow();
  }

  async getWindows() {
    return JSON.parse(await sendTestMessage({name: 'getWindows'}));
  }

  /**
   * Wait for a SWA window to be open.
   * @return {!Promise<string>}
   */
  async waitForSwaWindow() {
    const caller = getCaller();
    const appId = await repeatUntil(async () => {
      const ret = await sendTestMessage({name: 'findSwaWindow'});
      if (ret === 'none') {
        return pending(caller, 'Wait for SWA window');
      }
      return ret;
    });

    return appId;
  }

  /**
   * Executes a script in the context of a <preview-tag> element contained in
   * the window.
   * For SWA: It's the first chrome-untrusted://file-manager <iframe>.
   * For legacy: It's the first elements based on the `query`.
   * Responds with its output.
   *
   * @param {string} appId App window Id.
   * @param {!Array<string>} query Query to the <preview-tag> element (this is
   *     ignored for SWA).
   * @param {string} statement Javascript statement to be executed within the
   *     <preview-tag>.
   * @return {!Promise<*>} resolved with the return value of the `statement`.
   */
  async executeJsInPreviewTag(appId, query, statement) {
    return this.executeJsInPreviewTagSwa_(statement);
  }

  /**
   * Inject javascript statemenent in the first chrome-untrusted://file-manager
   * page found and respond with its output.
   * @private
   * @param {string} statement
   * @return {!Promise}
   */
  async executeJsInPreviewTagSwa_(statement) {
    const script = `try {
          let result = ${statement};
          result = result === undefined ? '@undefined@' : [result];
          window.domAutomationController.send(JSON.stringify(result));
        } catch (error) {
          const errorInfo = {'@error@':  error.message, '@stack@': error.stack};
          window.domAutomationController.send(JSON.stringify(errorInfo));
        }`;

    const command = {
      name: 'executeScriptInChromeUntrusted',
      data: script,
    };

    const response = await sendTestMessage(command);
    if (response === '"@undefined@"') {
      return undefined;
    }
    const output = JSON.parse(response);
    if ('@error@' in output) {
      console.error(output['@error@']);
      console.error('Original StackTrace:\n' + output['@stack@']);
      throw new ExecuteScriptError(
          'Error executing JS in Preview: ' + output['@error@']);
    } else {
      return output;
    }
  }

  /**
   * Waits until the expected URL shows in the last opened browser tab.
   * @param {string} expectedUrl
   * @return {!Promise} Promise to be fulfilled when the expected URL is shown
   *     in a browser window.
   */
  async waitForLastOpenedBrowserTabUrl(expectedUrl) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const command = {name: 'getLastActiveTabURL'};
      const activeBrowserTabURL = await sendTestMessage(command);
      if (activeBrowserTabURL !== expectedUrl) {
        return pending(
            caller, 'waitForActiveBrowserTabUrl: expected %j actual %j.',
            expectedUrl, activeBrowserTabURL);
      }
    });
  }

  /**
   * Returns whether a window exists with the expected origin.
   * @param {string} expectedOrigin
   * @return {!Promise<boolean>} Promise resolved with true or false depending
   *     on whether such window exists.
   */
  async windowOriginExists(expectedOrigin) {
    const command = {name: 'expectWindowOrigin', expectedOrigin};
    const windowExists = await sendTestMessage(command);
    return windowExists == 'true';
  }

  /**
   * Waits for the file list turns to the given contents.
   * @param {string} appId App window Id.
   * @param {Array<Array<string>>} expected Expected contents of file list.
   * @param {{orderCheck:(?boolean|undefined), ignoreFileSize:
   *     (?boolean|undefined), ignoreLastModifiedTime:(?boolean|undefined)}=}
   *     opt_options Options of the comparison. If orderCheck is true, it also
   *     compares the order of files. If ignoreLastModifiedTime is true, it
   *     compares the file without its last modified time.
   * @return {Promise} Promise to be fulfilled when the file list turns to the
   *     given contents.
   */
  waitForFiles(appId, expected, opt_options) {
    const options = opt_options || {};
    const caller = getCaller();
    return repeatUntil(async () => {
      const files = await this.callRemoteTestUtil('getFileList', appId, []);
      if (!options.orderCheck) {
        files.sort();
        expected.sort();
      }
      for (let i = 0; i < Math.min(files.length, expected.length); i++) {
        // Change the value received from the UI to match when comparing.
        if (options.ignoreFileSize) {
          files[i][1] = expected[i][1];
        }
        if (options.ignoreLastModifiedTime) {
          if (expected[i].length < 4) {
            // expected sometimes doesn't include the modified time at all, so
            // just remove from the data from UI.
            files[i].splice(3, 1);
          } else {
            files[i][3] = expected[i][3];
          }
        }
      }
      if (!chrome.test.checkDeepEq(expected, files)) {
        return pending(
            caller, 'waitForFiles: expected: %j actual %j.', expected, files);
      }
    });
  }

  /**
   * Waits until the number of files in the file list is changed from the given
   * number.
   * TODO(hirono): Remove the function.
   *
   * @param {string} appId App window Id.
   * @param {number} lengthBefore Number of items visible before.
   * @return {Promise} Promise to be fulfilled with the contents of files.
   */
  waitForFileListChange(appId, lengthBefore) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const files = await this.callRemoteTestUtil('getFileList', appId, []);
      files.sort();

      const notReadyRows =
          files.filter((row) => row.filter((cell) => cell == '...').length);

      if (notReadyRows.length === 0 && files.length !== lengthBefore &&
          files.length !== 0) {
        return files;
      } else {
        return pending(
            caller, 'The number of file is %d. Not changed.', lengthBefore);
      }
    });
  }

  /**
   * Waits until the given taskId appears in the executed task list.
   * @param {string} appId App window Id.
   * @param {!chrome.fileManagerPrivate.FileTaskDescriptor} descriptor Task to
   *     watch.
   * @param {!Array<string>} fileNames Name of files that should have been
   *     passed to the executeTasks().
   * @param {Array<Object>=} replyArgs arguments to reply to executed task.
   * @return {Promise} Promise to be fulfilled when the task appears in the
   *     executed task list.
   */
  waitUntilTaskExecutes(appId, descriptor, fileNames, replyArgs) {
    const caller = getCaller();
    return repeatUntil(async () => {
      if (!await this.callRemoteTestUtil(
              'taskWasExecuted', appId, [descriptor, fileNames])) {
        const tasks =
            await this.callRemoteTestUtil('getExecutedTasks', appId, []);
        const executedTasks = tasks.map((task) => {
          const {appId, taskType, actionId} = task.descriptor;
          const executedFileNames = task['fileNames'];
          return `${appId}|${taskType}|${actionId} for ${
              JSON.stringify(executedFileNames)}`;
        });
        return pending(caller, 'Executed task is %j', executedTasks);
      }
      if (replyArgs) {
        await this.callRemoteTestUtil(
            'replyExecutedTask', appId, [descriptor, replyArgs]);
      }
    });
  }

  /**
   * Check if the next tabforcus'd element has the given ID or not.
   * @param {string} appId App window Id.
   * @param {string} elementId String of 'id' attribute which the next
   *     tabfocus'd element should have.
   * @return {Promise} Promise to be fulfilled with the result.
   */
  async checkNextTabFocus(appId, elementId) {
    const result = await sendTestMessage({name: 'dispatchTabKey'});
    chrome.test.assertEq(
        result, 'tabKeyDispatched', 'Tab key dispatch failure');

    const caller = getCaller();
    return repeatUntil(async () => {
      let element =
          await this.callRemoteTestUtil('getActiveElement', appId, []);
      if (element && element.attributes['id'] === elementId) {
        return true;
      }
      // Try to check the shadow root.
      element =
          await this.callRemoteTestUtil('deepGetActiveElement', appId, []);
      if (element && element.attributes['id'] === elementId) {
        return true;
      }
      return pending(
          caller,
          'Waiting for active element with id: "' + elementId +
              '", but current is: "' + element.attributes['id'] + '"');
    });
  }

  /**
   * Returns a promise that repeatedly checks for a file with the given
   * name to be selected in the app window with the given ID. Typical
   * use
   *
   * await remoteCall.waitUntilSelected('file#0', 'hello.txt');
   * ... // either the test timed out or hello.txt is currently selected.
   *
   * @param {string} appId App window Id.
   * @param {string} fileName the name of the file to be selected.
   * @return {Promise<boolean>} Promise that indicates if selection was
   *     successful.
   */
  waitUntilSelected(appId, fileName) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const selected =
          await this.callRemoteTestUtil('selectFile', appId, [fileName]);
      if (!selected) {
        return pending(caller, `File ${fileName} not yet selected`);
      }
    });
  }

  /**
   * Waits until the current directory is changed.
   * @param {string} appId App window Id.
   * @param {string} expectedPath Path to be changed to.
   * @return {Promise} Promise to be fulfilled when the current directory is
   *     changed to expectedPath.
   */
  waitUntilCurrentDirectoryIsChanged(appId, expectedPath) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const path =
          await this.callRemoteTestUtil('getBreadcrumbPath', appId, []);
      if (path !== expectedPath) {
        return pending(
            caller, 'Expected path is %s got %s', expectedPath, path);
      }
    });
  }

  /**
   * Expands tree item.
   * @param {string} appId App window Id.
   * @param {string} query Query to the <tree-item> element.
   */
  async expandTreeItemInDirectoryTree(appId, query) {
    await this.waitForElement(appId, query);
    const elements = await this.callRemoteTestUtil(
        'queryAllElements', appId, [`${query}[expanded]`]);
    // If it's already expanded just set the focus on directory tree.
    if (elements.length > 0) {
      return this.callRemoteTestUtil('focus', appId, ['#directory-tree']);
    }

    // We must wait until <tree-item> has attribute [has-children=true]
    // otherwise it won't expand. We must also to account for the case
    // :not([expanded]) to ensure it has NOT been expanded by some async
    // operation since the [expanded] checks above.
    const expandIcon =
        query + ':not([expanded]) > .tree-row[has-children=true] .expand-icon';
    await this.waitAndClickElement(appId, expandIcon);
    // Wait for the expansion to finish.
    await this.waitForElement(appId, query + '[expanded]');
    // Force the focus on directory tree.
    await this.callRemoteTestUtil('focus', appId, ['#directory-tree']);
  }

  /**
   * Expands directory tree for specified path.
   */
  expandDirectoryTreeFor(appId, path, volumeType = 'downloads') {
    return this.expandDirectoryTreeForInternal_(
        appId, path.split('/'), 0, volumeType);
  }

  /**
   * Internal function for expanding directory tree for specified path.
   */
  async expandDirectoryTreeForInternal_(appId, components, index, volumeType) {
    if (index >= components.length - 1) {
      return;
    }

    // First time we should expand the root/volume first.
    if (index === 0) {
      await this.expandVolumeInDirectoryTree(appId, volumeType);
      return this.expandDirectoryTreeForInternal_(
          appId, components, index + 1, volumeType);
    }
    const path = '/' + components.slice(1, index + 1).join('/');
    await this.expandTreeItemInDirectoryTree(
        appId, `[full-path-for-testing="${path}"]`);
    await this.expandDirectoryTreeForInternal_(
        appId, components, index + 1, volumeType);
  }

  /**
   * Expands download volume in directory tree.
   */
  expandDownloadVolumeInDirectoryTree(appId) {
    return this.expandVolumeInDirectoryTree(appId, 'downloads');
  }

  /**
   * Expands download volume in directory tree.
   */
  expandVolumeInDirectoryTree(appId, volumeType) {
    return this.expandTreeItemInDirectoryTree(
        appId, `[volume-type-for-testing="${volumeType}"]`);
  }

  /**
   * Wait until the expected number of volumes is mounted.
   * @param {number} expectedVolumesCount Expected number of mounted volumes.
   * @return {Promise} promise Promise to be fulfilled.
   */
  async waitForVolumesCount(expectedVolumesCount) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const volumesCount = await sendTestMessage({name: 'getVolumesCount'});
      if (volumesCount === expectedVolumesCount.toString()) {
        return;
      }
      const msg =
          'Expected number of mounted volumes: ' + expectedVolumesCount +
          '. Actual: ' + volumesCount;
      return pending(caller, msg);
    });
  }

  /**
   * Isolates the specified banner to test. The banner is still checked against
   * it's filters, but is now the top priority banner.
   * @param {string} appId App window Id
   * @param {string} bannerTagName Banner tag name in lowercase to isolate.
   */
  async isolateBannerForTesting(appId, bannerTagName) {
    await this.waitFor('isFileManagerLoaded', appId, true);
    chrome.test.assertTrue(await this.callRemoteTestUtil(
        'isolateBannerForTesting', appId, [bannerTagName]));
  }

  /**
   * Disables banners from attaching to the DOM.
   * @param {string} appId App window Id
   */
  async disableBannersForTesting(appId) {
    await this.waitFor('isFileManagerLoaded', appId, true);
    chrome.test.assertTrue(
        await this.callRemoteTestUtil('disableBannersForTesting', appId, []));
  }

  /**
   * Sends text to the search box in the Files app.
   * @param {string} appId App window Id
   * @param {string} text The text to type in the search box.
   */
  async typeSearchText(appId, text) {
    const searchBoxInput = ['#search-box cr-input'];

    // Focus the search box.
    await this.waitAndClickElement(appId, '#search-button');

    // Wait for search to fully open.
    await this.waitForElementLost(appId, '#search-wrapper[collapsed]');

    // Input the text.
    await this.inputText(appId, searchBoxInput, text);

    // Notify the element of the input.
    chrome.test.assertTrue(await this.callRemoteTestUtil(
        'fakeEvent', appId, ['#search-box cr-input', 'input']));
  }

  /**
   * Waits for the search box auto complete list to appear.
   * @param {string} appId
   * @return {!Promise<!Array<string>>} Array of the names in the auto complete
   *     list.
   */
  async waitForSearchAutoComplete(appId) {
    // Wait for the list to appear.
    await this.waitForElement(appId, '#autocomplete-list li');

    // Return the result.
    const elements = await this.callRemoteTestUtil(
        'deepQueryAllElements', appId, ['#autocomplete-list li']);
    return elements.map((element) => element.text);
  }

  /**
   * Disable nudges from expiring for testing.
   * @param {string} appId App window Id
   */
  async disableNudgeExpiry(appId) {
    await this.waitFor('isFileManagerLoaded', appId, true);
    chrome.test.assertTrue(
        await this.callRemoteTestUtil('disableNudgeExpiry', appId, []));
  }

  /**
   * Selects the file and displays the context menu for the file.
   * @return {!Promise<void>} resolved when the context menu is visible.
   */
  async showContextMenuFor(appId, fileName) {
    // Select the file.
    await this.waitUntilSelected(appId, fileName);

    // Right-click to display the context menu.
    await this.waitAndRightClick(appId, '.table-row[selected]');

    // Wait for the context menu to appear.
    await this.waitForElement(appId, '#file-context-menu:not([hidden])');

    // Wait for the tasks to be fully fetched.
    await this.waitForElement(appId, '#tasks[get-tasks-completed]');
  }

  /**
   * @param {string} appId App window Id.
   * @return {!Promise<void>}
   */
  async dismissMenu(appId) {
    await this.fakeKeyDown(appId, 'body', 'Escape', false, false, false);
  }

  /**
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} query Query to find the elements.
   * @return {!Promise<!Array<!ElementObject>>} Promise to be fulfilled with the
   *     elements.
   * @private
   */
  async queryElements_(appId, query) {
    if (typeof query === 'string') {
      query = [query];
    }
    return this.callRemoteTestUtil('deepQueryAllElements', appId, query);
  }

  /**
   * Returns the menu as ElementObject and its menu-items (including separators)
   * in the `items` property.
   * @param {string} appId App window Id.
   * @param {string|!Array<string>} menu The name of the menu.
   * @return {!Promise<undefined|!ElementObject>} Promise to be fulfilled with
   *     the menu.
   */
  async getMenu(appId, menu) {
    let menuId = '';
    // TODO: Implement for other menus.
    if (menu === 'context-menu') {
      menuId = '#file-context-menu';
    } else if (menu == 'tasks') {
      menuId = '#tasks-menu';
    }

    if (!menuId) {
      console.error(`Invalid menu '${menu}'`);
      return;
    }

    // Get the top level menu element.
    const menuElement = await this.waitForElement(appId, menuId);
    // Query all the menu items.
    menuElement.items = await this.queryElements_(appId, `${menuId} > *`);
    return menuElement;
  }

  /**
   * Displays the "tasks" menu from the "OPEN" button dropdown.
   * The caller code has to prepare the selection to have multiple tasks.
   * @param {string} appId App window Id.
   */
  async expandOpenDropdown(appId) {
    // Wait the OPEN button to have multiple tasks.
    await this.waitAndClickElement(appId, '#tasks[multiple]');
  }

  /**
   * Check if an item is pinned on drive or not.
   * @param {string} appId app window ID.
   * @param {string} path Path from the drive mount point, e.g. /root/test.txt
   * @param {boolean} status Pinned status to expect drive item to be.
   */
  async expectDriveItemPinnedStatus(appId, path, status) {
    await this.waitFor('isFileManagerLoaded', appId, true);
    chrome.test.assertEq(
        await sendTestMessage({
          appId,
          name: 'isItemPinned',
          path,
        }),
        String(status));
  }

  /**
   * Send a delete event via the `OnFilesChanged` drivefs delegate method.
   * @param {string} appId app window ID.
   * @param {string} path Path from the drive mount point, e.g. /root/test.txt
   */
  async sendDriveCloudDeleteEvent(appId, path) {
    await this.waitFor('isFileManagerLoaded', appId, true);
    await sendTestMessage({
      appId,
      name: 'sendDriveCloudDeleteEvent',
      path,
    });
  }

  /**
   * Wait for the nudge with the given text to be visible.
   *
   * @param {string} appId app window ID.
   * @param {string} expectedText Text that should be displayed in the Nudge.
   * @return {!Promise<boolean>}
   */
  async waitNudge(appId, expectedText) {
    const caller = getCaller();
    return repeatUntil(async () => {
      const nudgeDot = await this.waitForElementStyles(
          appId, ['xf-nudge', '#dot'], ['left']);
      if (nudgeDot.renderedLeft < 0) {
        return pending(caller, 'Wait nudge to appear');
      }

      const actualText =
          await this.waitForElement(appId, ['xf-nudge', '#text']);
      console.log(actualText);
      chrome.test.assertEq(actualText.text, expectedText);

      return true;
    });
  }
}