chromium/chrome/test/data/extensions/api_test/file_system_provider/service_worker/provider.js

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {promisifyWithLastError, Queue} from '/_test_resources/api_test/file_system_provider/service_worker/helpers.js';

/**
 * Splits the path into dir name and base name, e.g. '/a/b/c' -> '/a/b', 'c'.
 *
 * @param {string} pathString
 * @returns {{dirPath: string, fileName:string}}
 */
function splitPath(pathString) {
  const path = pathString.split('/');
  const fileName = path.pop();
  return {dirPath: path.join('/'), fileName};
}

/**
 * @param {string} text
 * @returns {!ArrayBuffer}
 */
function textToBuffer(text) {
  return new TextEncoder().encode(text).buffer;
}

class Entry {
  /**
   * @param {string} key name identifying the entry in a directory (separate
   *    from name in the metadata to allow returning a different value there).
   * @param {{
   *  name: string,
   *  isDirectory: boolean,
   *  size: (number|undefined),
   *  modificationTime: !Date
   * }} metadata
   * @param {?string} contents
   * @param {Array<!Entry>} children
   */
  constructor(key, metadata, contents, children) {
    this.key = key;
    this.metadata = metadata;
    this.contents = contents;
    /** @type {!Object<string, !Entry>} */
    this.children = Object.fromEntries((children || []).map(e => [e.key, e]));
  }

  /**
   * @param {string} name
   * @param {!Date} modificationTime
   * @param {string} contents
   */
  static file(name, modificationTime, contents) {
    return new Entry(
        name, {
          name,
          isDirectory: false,
          size: contents.length,
          modificationTime,
        },
        contents, null);
  }

  /**
   * @param {string} name
   * @param {!Date} modificationTime
   * @param {!Array<!Entry>} children
   */
  static dir(name, modificationTime, children) {
    return new Entry(
        name, {
          name,
          size: 0,
          isDirectory: true,
          modificationTime,
        },
        null, children);
  }
};

export class TestFileSystemProvider {
  constructor(fileSystemId) {
    this.fileSystemId = fileSystemId;
    /**
     * Filesystem contents (data and metadata). The key is a full path, and the
     * value is an object containing metadata and file contents.
     *
     * @private {!Entry}
     */
    this.root = Entry.dir('', new Date(2014, 4, 28, 10, 39, 15), [
      // Directory with actions.
      Entry.dir(
          TestFileSystemProvider.DIR_WITH_ACTIONS,
          new Date(2014, 1, 25, 7, 36, 12), []),
      // Directory with no actions.
      Entry.dir(
          TestFileSystemProvider.DIR_WITH_NO_ACTIONS,
          new Date(2014, 1, 25, 7, 36, 12), []),
      // Read error
      Entry.file(
          TestFileSystemProvider.FILE_FAIL, new Date(2014, 1, 25, 7, 36, 12),
          TestFileSystemProvider.INITIAL_TEXT),
      // Open blocks until unblocked manually.
      Entry.file(
          TestFileSystemProvider.FILE_BLOCK_OPEN,
          new Date(2014, 1, 26, 8, 37, 13),
          TestFileSystemProvider.INITIAL_TEXT),
      // Read blocks until unblocked manually.
      Entry.file(
          TestFileSystemProvider.FILE_BLOCK_IO,
          new Date(2014, 1, 26, 8, 37, 13),
          TestFileSystemProvider.INITIAL_TEXT),
      // Read returns data in chunks.
      Entry.file(
          TestFileSystemProvider.FILE_READ_SUCCESS,
          new Date(2014, 1, 25, 7, 36, 12),
          TestFileSystemProvider.INITIAL_TEXT),
      // A big file to test access at offset greater than the max unsigned
      // 32-bit value.
      (() => {
        const entry = Entry.file(
            TestFileSystemProvider.FILE_BIG, new Date(2014, 1, 25, 7, 36, 12),
            '');
        entry.metadata.size = 6 * 1024 * 1024 * 1024;
        return entry;
      })(),
      // Read returns more data than asked for.
      Entry.file(
          TestFileSystemProvider.FILE_TOO_LARGE_CHUNK,
          new Date(2014, 1, 25, 7, 36, 12), 'A'.repeat(1024 * 2)),
      // Read handlers invokes both success and error callbacks.
      Entry.file(
          TestFileSystemProvider.FILE_INVALID_CALLBACK,
          new Date(2014, 1, 25, 7, 36, 12), 'A'.repeat(1024 * 2)),
      // File with negative size.
      (() => {
        const entry = Entry.file(
            TestFileSystemProvider.FILE_NEGATIVE_SIZE,
            new Date(2014, 1, 25, 7, 36, 12), 'A'.repeat(1024 * 2));
        entry.metadata.size = -entry.metadata.size;
        return entry;
      })(),
      // File with invalid date for the modification time.
      Entry.file(
          TestFileSystemProvider.FILE_INVALID_DATE, new Date('Invalid date.'),
          ''),
      // File with only isDirectory flag as metadata.
      (() => {
        const entry = Entry.file(
            TestFileSystemProvider.FILE_ONLY_TYPE,
            new Date(2014, 1, 25, 7, 36, 12), '');
        const {isDirectory} = entry.metadata;
        entry.metadata = {isDirectory};
        return entry;
      })(),
      // File with only size as metadata.
      (() => {
        const entry = Entry.file(
            TestFileSystemProvider.FILE_ONLY_TYPE_AND_SIZE,
            new Date(2014, 1, 25, 7, 36, 12), 'A'.repeat(1024 * 4));
        const {isDirectory, size} = entry.metadata;
        entry.metadata = {isDirectory, size};
        return entry;
      })(),
    ]);

    /**
     * Map of opened files, from a `openRequestId` to `filePath`.
     *
     * @private {!Object<number, string>}
     */
    this.openedFiles = {};

    /**
     * Records max number of opened files any time a file is opened.
     *
     * @private {number}
     */
    this.maxOpenedFiles = 0;

    /**
     * A queue of recorded event per event name. Allows the remote test to read
     * the events happening in the FSP implementation asynchronously.
     *
     * @private {!Object<string, !Queue>}
     */
    this.eventQueues = {};

    /**
     * A map of FSP handler name to a Bound FSP handler, so handlers can be
     * added or removed mid-test.
     *
     * @private {!Object<string, !Function>}
     */
    this.handlers = {};

    /**
     * A map of FSP requests that's been deliberately stalled. The key is FSP
     * request ID, and the value are the arguments passed to the request
     * handler.
     *
     * @private {!Object<number, function()>}
     */
    this.stalledRequests = {};

    /**
     * Configuration set by tests.
     *
     * @private {!Object<string, ?>}
     */
    this.testConfig = {};
  }

  setUpProviderListeners() {
    this.setHandlerEnabled('onAbortRequested', true);
    this.setHandlerEnabled('onAddWatcherRequested', true);
    this.setHandlerEnabled('onCloseFileRequested', true);
    this.setHandlerEnabled('onConfigureRequested', true);
    this.setHandlerEnabled('onCopyEntryRequested', true);
    this.setHandlerEnabled('onCreateDirectoryRequested', true);
    this.setHandlerEnabled('onCreateFileRequested', true);
    this.setHandlerEnabled('onDeleteEntryRequested', true);
    this.setHandlerEnabled('onExecuteActionRequested', true);
    this.setHandlerEnabled('onGetActionsRequested', true);
    this.setHandlerEnabled('onGetMetadataRequested', true);
    this.setHandlerEnabled('onMountRequested', true);
    this.setHandlerEnabled('onMoveEntryRequested', true);
    this.setHandlerEnabled('onOpenFileRequested', true);
    this.setHandlerEnabled('onReadDirectoryRequested', true);
    this.setHandlerEnabled('onReadFileRequested', true);
    this.setHandlerEnabled('onRemoveWatcherRequested', true);
    this.setHandlerEnabled('onTruncateRequested', true);
    this.setHandlerEnabled('onUnmountRequested', true);
    this.setHandlerEnabled('onWriteFileRequested', true);
  }

  /**
   * Enable or disable the listener for a named FSP event (e.g.
   * onOpenFileRequested, onAbortRequested).
   *
   * @suppress {checkTypes}
   */
  setHandlerEnabled(handlerName, enabled) {
    if (!(handlerName in this)) {
      throw new Error(
          `${this.constructor.name} does not implement ${handlerName}`);
    }
    if (!(handlerName in this.handlers)) {
      this.handlers[handlerName] = this[handlerName].bind(this);
    }
    if (enabled) {
      chrome.fileSystemProvider[handlerName].addListener(
          this.handlers[handlerName]);
    } else {
      chrome.fileSystemProvider[handlerName].removeListener(
          this.handlers[handlerName]);
    }
  }

  setUpCommandListener() {
    const listener = (msg, sender, sendResponse) => {
      const {commandId, args} = msg;
      this.handleCommand(commandId, ...args).then(sendResponse);
      return true;  // Indicate that we want to respond asynchronously.
    };
    // Listen to both events to handle messages sent both from the same or from
    // a different extension.
    chrome.runtime.onMessageExternal.addListener(listener);
    chrome.runtime.onMessage.addListener(listener);
  }

  /** @suppress {checkTypes} */
  async handleCommand(commandId, ...args) {
    const result = {};
    try {
      if (commandId in this) {
        result.response = await this[commandId](...args);
      } else {
        result.error = `unhandled: ${commandId}`;
      }
    } catch (error) {
      result.error = error.toString();
    }
    return result;
  }

  /**
   * Called by the test. Add files to the provider's filesystem.
   *
   * @param {!Object<string, !Entry>} files a map of paths to entries.
   */
  addFiles(files) {
    for (const path of Object.keys(files)) {
      const file = files[path];
      const {dirPath} = splitPath(path);
      // Restore Date objects after receiving data via postMessage.
      file.metadata.modificationTime = new Date(file.metadata.modificationTime);
      const entry =
          new Entry(file.metadata.name, file.metadata, file.contents, null);
      this.findEntryByPath(dirPath).children[entry.metadata.name] = entry;
    }
  }

  /**
   * Called by the test. Gets contents of a given file.
   *
   * @param {string} filePath
   * @returns {string|null} The current text contents of the file.
   */
  getFileContents(filePath) {
    const entry = this.findEntryByPath(filePath);
    return entry ? entry.contents : null;
  }

  /**
   * Called by the test. Returns the number of files that are currently open.
   *
   * @returns {number}
   */
  getOpenedFiles() {
    return Object.keys(this.openedFiles).length;
  }

  /**
   * Called by the test. Causes a change notification to be sent for an entry.
   *
   * @param {string} entryPath
   * @param {boolean} recursive
   * @param {string} tag
   */
  async triggerNotify(entryPath, recursive, tag) {
    return promisifyWithLastError(chrome.fileSystemProvider.notify, {
      fileSystemId: this.fileSystemId,
      observedPath: entryPath,
      recursive,
      changeType: 'CHANGED',
      tag,
    });
  }

  /**
   * Opens a tab from the extension hosting this provider.
   *
   * @param {string} url
   * @returns {!Promise<number>}
   */
  async openTab(url) {
    const tab = await promisifyWithLastError(chrome.tabs.create, {url});
    return tab.id;
  }

  /**
   * Closes a tab previously opened with |openTab|.
   *
   * @param {number} tabId
   */
  async closeTab(tabId) {
    await chrome.tabs.remove(tabId);
  }

  /**
   * Opens a window from the extension hosting this provider.
   *
   * @param {string} url
   * @returns {!Promise<number>}
   */
  async openWindow(url) {
    return await promisifyWithLastError(chrome.windows.create, {url});
  }

  /**
   * Called by the test. Gets the least recent event recorded for a given event
   * name. Will block until there is at least one in the queue.
   *
   * @param {string} funcName FSP function name.
   * @returns {!Object} the 'options' argument passed to the FSP call.
   */
  async waitForEvent(funcName) {
    return this.getEventQueue(funcName).pop();
  }

  /**
   * Called by the test. Gets the count of events for an event name, to check
   * that there's been no events without blocking. When using, ensure that "no
   * event" condition is gated by some other condition you can wait for (e.g. a
   * request failing and returning a result).
   *
   * @param {string} eventName
   * @returns {number}
   */
  getEventCount(eventName) {
    return this.getEventQueue(eventName).size();
  }

  /**
   * Called by the tests to control provider configuration for different test
   * scenarios.
   * @param {string} key
   * @param {?} value
   */
  setConfig(key, value) {
    if (value === undefined || value === null) {
      delete this.testConfig[key];
    } else {
      this.testConfig[key] = value;
    }
  }

  /**
   * Called by the FSP. Adds a record of a function call to the queue. The test
   * will read from this queue to wait for a specific FSP call to happen.
   *
   * @param {string} name event name.
   * @param {!Object} arg additional data associated with the event.
   */
  recordEvent(name, arg) {
    this.getEventQueue(name).push(arg);
  }

  async stallRequest(name, options) {
    this.recordEvent(`${name}Stalled`, options);
    return new Promise(resolve => {
      this.stalledRequests[options.requestId] = resolve;
    })
  }

  /**
   * Called by the test to resume a stalled request.
   *
   * @param {number} requestId
   */
  continueRequest(requestId) {
    const continueFn = this.stalledRequests[requestId];
    if (continueFn) {
      continueFn();
    } else {
      throw new Error(`continue request: request ID not found: ${requestId}`);
    }
  }

  /**
   * Gets or creates a test event queue for a function.
   *
   * @param {string} name
   * @returns {!Queue}
   */
  getEventQueue(name) {
    if (!(name in this.eventQueues)) {
      this.eventQueues[name] = new Queue();
    }
    return this.eventQueues[name];
  }

  /**
   * Clears all the state mutated by tests or FSP handlers.
   */
  resetState() {
    this.openedFiles = {};
    this.eventQueues = {};
    this.maxOpenedFiles = 0;
    this.stalledRequests = {};
    this.testConfig = {};
  }

  /**
   * Finds a file or directory entry by path.
   *
   * @param {string} pathString
   * @returns {?Entry}
   */
  findEntryByPath(pathString) {
    if (pathString === '/') {
      return this.root;
    }
    let path = pathString.split('/');
    if (path[0] != '') {
      // Must start with "/"
      return null;
    }
    path = path.slice(1);

    let entry = this.root;
    for (const fileName of path) {
      const child = entry.children[fileName];
      if (child) {
        entry = child;
      } else {
        return null;
      }
    }
    return entry;
  }

  onAbortRequested(options, onSuccess, onError) {
    this.recordEvent('onAbortRequested', options);
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    onSuccess();
  }

  /**
   * FSP: implementation for adding an entry watcher.
   *
   * @param {!chrome.fileSystemProvider.AddWatcherRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(string)} onError Error callback with an error code.
   */
  onAddWatcherRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.entryPath === '/' + TestFileSystemProvider.FILE_FAIL) {
      onError(chrome.fileSystemProvider.ProviderError.FAILED);
      return;
    }

    if (this.findEntryByPath(options.entryPath)) {
      onSuccess();
      return;
    }

    onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
  };

  /**
   * FSP: implementation for the file close request event. The file,
   * previously opened with <code>openRequestId</code> will be closed.
   *
   * @param {!chrome.fileSystemProvider.CloseFileRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onCloseFileRequested(options, onSuccess, onError) {
    this.recordEvent('onCloseFileRequested', options);

    if (options.fileSystemId !== this.fileSystemId ||
        !this.openedFiles[options.openRequestId]) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    delete this.openedFiles[options.openRequestId];
    onSuccess();
  };

  /**
   *
   * @param {!chrome.fileSystemProvider.ConfigureRequestedOptions} options
   * @param {function()} onSuccess
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onConfigureRequested(options, onSuccess, onError) {
    this.recordEvent('onConfigureRequested', options);
    const delay = this.testConfig['onConfigureRequestedDelayMs'];
    if (delay) {
      // Simulates a delayed configuration success.
      setTimeout(onSuccess, delay);
      return;
    }
    const error = this.testConfig['onConfigureRequestedError'];
    if (error) {
      onError(error);
    } else {
      onSuccess();
    }
  }

  /**
   * FSP: implementation of copying an entry within the same file system.
   *
   * @param {!chrome.fileSystemProvider.CopyEntryRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback with an error code.
   */
  onCopyEntryRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.sourcePath === '/') {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const source = this.findEntryByPath(options.sourcePath);
    if (!source) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    if (this.findEntryByPath(options.targetPath)) {
      onError(chrome.fileSystemProvider.ProviderError.EXISTS);
      return;
    }

    // Copy the metadata, but change the 'name' field.
    /** @suppress {undefinedVars} */
    const dest = structuredClone(source);
    const {dirPath, fileName} = splitPath(options.targetPath);
    dest.name = fileName;
    this.findEntryByPath(dirPath).children[dest.name] = dest;

    onSuccess();
  }

  /**
   * FSP: implementation of creating a directory within the same file system.
   *
   * @param {!chrome.fileSystemProvider.CreateDirectoryRequestedOptions} options
   *  Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *  callback with an error code.
   */
  onCreateDirectoryRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.directoryPath === '/' || options.recursive) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    if (this.findEntryByPath(options.directoryPath)) {
      onError(chrome.fileSystemProvider.ProviderError.EXISTS);
      return;
    }

    // Check new directory path is valid.
    const dirPathSplit = splitPath(options.directoryPath);
    const parentDir = this.findEntryByPath(dirPathSplit.dirPath);
    const dirName = dirPathSplit.fileName;
    if (!parentDir) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }
    if (!parentDir.metadata.isDirectory) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_A_DIRECTORY);
      return;
    }

    // Add new directory.
    const emptyDirMetadata = {
      isDirectory: true,
      name: dirName,
      modificationTime: new Date(2014, 4, 28, 10, 39, 15),
    }

    const entry = new Entry(dirName, emptyDirMetadata, null, null);
    parentDir.children[dirName] = entry;

    onSuccess();
  }

  /**
   * FSP: implementation for the file create request event.
   *
   * @param {!chrome.fileSystemProvider.CreateFileRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback with an error code.
   */
  onCreateFileRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.filePath === '/') {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    if (this.findEntryByPath(options.filePath)) {
      onError(chrome.fileSystemProvider.ProviderError.EXISTS);
      return;
    }

    const {dirPath, fileName} = splitPath(options.filePath);
    const dir = this.findEntryByPath(dirPath);
    if (!dir) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    dir.children[fileName] = Entry.file(fileName, new Date(), '');
    onSuccess();
  };

  /**
   * FSP: implementation for the execute action request event.
   *
   * @param {!chrome.fileSystemProvider.ExecuteActionRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback with an error code.
   */
  onExecuteActionRequested(options, onSuccess, onError) {
    this.recordEvent('onExecuteActionRequested', options);
    if (options.actionId === TestFileSystemProvider.ACTION_ID) {
      onSuccess();
    } else {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
    }
  }

  /**
   * FSP: implementation for returning a list of actions for the requested
   * entry.
   *
   * @param {chrome.fileSystemProvider.GetActionsRequestedOptions} options
   *     Options.
   * @param {function(Array<Object>)} onSuccess Success callback with a list of
   *     actions.
   * @param {function(string)} onError Error callback with an error code.
   */
  onGetActionsRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.entryPaths.indexOf(
            '/' + TestFileSystemProvider.DIR_WITH_NO_ACTIONS) !== -1) {
      onSuccess([]);
      return;
    }

    if (options.entryPaths.indexOf(
            '/' + TestFileSystemProvider.DIR_WITH_ACTIONS) !== -1) {
      onSuccess(TestFileSystemProvider.ACTIONS);
      return;
    }

    onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
  }

  /**
   * FSP: implementation for the metadata request event.
   *
   * @param {chrome.fileSystemProvider.GetMetadataRequestedOptions} options
   *     Options.
   * @param {function(!Object)} onSuccess Success callback with metadata passed
   *     an argument.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback with an error code.
   */
  onGetMetadataRequested(options, onSuccess, onError) {
    this.recordEvent('onGetMetadataRequested', options);
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const entry = this.findEntryByPath(options.entryPath);
    if (!entry) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    // Returning a thumbnail while not requested is not allowed for performance
    // reasons. Remove the field if needed. However, do not remove it for one
    // file, to simulate an error.
    if (!options.thumbnail && entry.metadata.thumbnail &&
        options.entryPath !==
            `/${TestFileSystemProvider.FILE_ALWAYS_VALID_THUMBNAIL}`) {
      const metadataWithoutThumbnail = {
        ...entry.metadata,
        thumbnail: undefined,
      };
      onSuccess(metadataWithoutThumbnail);
      return;
    }
    onSuccess(entry.metadata);
  };

  /**
   * FSP: implementation for the file open request event. Further file
   * operations will be associated with the <code>requestId</code>.
   *
   * @param {!chrome.fileSystemProvider.OpenFileRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onOpenFileRequested(options, onSuccess, onError) {
    this.recordEvent('onOpenFileRequested', options);

    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const entry = this.findEntryByPath(options.filePath);
    if (!entry || entry.metadata.isDirectory) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    this.openedFiles[options.requestId] = options.filePath;
    this.maxOpenedFiles =
        Math.max(this.maxOpenedFiles, Object.keys(this.openedFiles).length);

    if (options.filePath === '/' + TestFileSystemProvider.FILE_BLOCK_OPEN) {
      this.stallRequest('onOpenFileRequested', options).then(onSuccess);
      return;
    }

    onSuccess();
  };

  /**
   * FSP: implementation of moving an entry within the same file system.
   *
   * @param {!chrome.fileSystemProvider.MoveEntryRequestedOptions} options
   *  Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *  callback with an error code.
   */
  onMoveEntryRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.sourcePath === '/') {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const source = this.findEntryByPath(options.sourcePath);
    if (!source) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    if (this.findEntryByPath(options.targetPath)) {
      onError(chrome.fileSystemProvider.ProviderError.EXISTS);
      return;
    }

    // Move the metadata with changing the 'name' field.
    let {dirPath, fileName} = splitPath(options.targetPath);
    const dir = this.findEntryByPath(dirPath);
    if (!dir) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }
    if (!dir.metadata.isDirectory) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_A_DIRECTORY);
      return;
    }
    source.metadata.name = fileName;
    dir.children[fileName] = source;

    // Remove the source file.
    ({dirPath, fileName} = splitPath(options.sourcePath));
    delete this.findEntryByPath(dirPath).children[fileName];

    onSuccess();
  }

  /**
   * Returns entries in the requested directory.
   *
   * @param {!chrome.fileSystemProvider.ReadDirectoryRequestedOptions} options
   *     Options.
   * @param {function(Array<Object>, boolean)} onSuccess Success callback with
   *     a list of entries. May be called multiple times.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback with an error code.
   */
  onReadDirectoryRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const entry = this.findEntryByPath(options.directoryPath);
    if (!entry.metadata.isDirectory) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    const children = Object.values(entry.children);
    // Send one-by-one to have multiple result callbacks.
    for (let i = 0; i < children.length; i++) {
      onSuccess(
          [children[i].metadata],
          /*hasMore=*/ i < children.length - 1);
    }
  }


  /**
   * FSP: requests reading contents of a file, previously opened with <code>
   * openRequestId</code>.
   *
   * @param {!chrome.fileSystemProvider.ReadFileRequestedOptions} options
   *     Options.
   * @param {function(ArrayBuffer, boolean)} onSuccess Success callback.
   * @param {function(string)} onError Error callback.
   */
  onReadFileRequested(options, onSuccess, onError) {
    this.recordEvent('onReadFileRequested', options);

    if (options.fileSystemId !== this.fileSystemId ||
        !this.openedFiles[options.openRequestId]) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const filePath = this.openedFiles[options.openRequestId];
    const entry = this.findEntryByPath(filePath);
    if (!entry) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const sendFileInChunks = (file) => {
      const buffer = textToBuffer(file.contents);
      const CHUNK_SIZE = 5;
      for (let i = 0; i < buffer.byteLength; i += CHUNK_SIZE) {
        onSuccess(
            /*data=*/ buffer.slice(
                i, Math.min(buffer.byteLength, i + CHUNK_SIZE)),
            /*hasMore=*/ i + CHUNK_SIZE < buffer.byteLength);
      }
    };

    if (filePath === '/' + TestFileSystemProvider.FILE_TOO_LARGE_CHUNK) {
      // Invalid file: returns more data than the file size.
      const buffer = textToBuffer('A'.repeat(entry.metadata.size * 4));
      onSuccess(buffer, /*hasMore=*/ true);
      onSuccess(buffer, /*hasMore=*/ true);
      onSuccess(buffer, /*hasMore=*/ true);
      onSuccess(buffer, /*hasMore=*/ false);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_INVALID_CALLBACK) {
      // Invalid file: invokes both success and error callbacks.
      const buffer = textToBuffer('A'.repeat(options.length));
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      onSuccess(buffer, /*hasMore=*/ false);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_FAIL) {
      onError(chrome.fileSystemProvider.ProviderError.FAILED);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_DENIED) {
      onError(chrome.fileSystemProvider.ProviderError.ACCESS_DENIED);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_BLOCK_IO) {
      // Block the read until it's unblocked.
      this.stallRequest('onReadFileRequested', options)
          .then(() => sendFileInChunks(entry));
      return;
    }

    if (filePath == '/' + TestFileSystemProvider.FILE_BIG) {
      // This file is not intended to be read below the max 32-bit unsigned
      // value, so fail immediately.
      if (options.offset <= 2 ** 32 - 1) {
        onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
        return;
      }
      // The return value does not matter, so just return a string of "A"s.
      // Encoded length is the same as string length for ASCII.
      onSuccess(
          /*data=*/ textToBuffer('A'.repeat(options.length)),
          /*hasMore=*/ false,
      );
      return;
    }

    sendFileInChunks(entry);
  }

  /**
   * FSP: implementation for removing an entry watcher.
   *
   * @param {!chrome.fileSystemProvider.AddWatcherRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(string)} onError Error callback with an error code.
   */
  onRemoveWatcherRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const error = this.testConfig['onRemoveWatcherRequestedError'];
    if (error) {
      onError(error);
      return;
    }

    if (!this.findEntryByPath(options.entryPath)) {
      onSuccess();
      return;
    }

    onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
  };

  /**
   * FSP: implementation for truncating a file to the specified length.
   *
   * @param {!chrome.fileSystemProvider.TruncateRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(string)} onError Error callback.
   */
  onTruncateRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    let entry = this.findEntryByPath(options.filePath);
    if (!entry) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    // Truncating beyond the end of the file.
    if (options.length > entry.metadata.size) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    entry.metadata.size = options.length;
    onSuccess();
  }


  /**
   * FSP: requests to mount this filesystem.
   *
   * @param {function()} onSuccess Success callback.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onMountRequested(onSuccess, onError) {
    // This handler does not take the options arguments.
    this.recordEvent('onMountRequested', {});
    onSuccess();
  };

  /**
   * FSP: requests to unmount this filesystem.
   *
   * @param {!chrome.fileSystemProvider.UnmountRequestedOptions} options
   * @param {function()} onSuccess Success callback.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onUnmountRequested(options, onSuccess, onError) {
    this.recordEvent('onUnmountRequested', options);
    const error = this.testConfig['onUnmountRequestedError'];
    if (error) {
      onError(error);
    } else {
      onSuccess();
    }
  };

  /**
   * FSP: requests writing contents to a file, previously opened with <code>
   * openRequestId</code>.
   *
   * @param {!chrome.fileSystemProvider.WriteFileRequestedOptions} options
   *     Options.
   * @param {function()} onSuccess Success callback.
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *     callback.
   */
  onWriteFileRequested(options, onSuccess, onError) {
    this.recordEvent('onWriteFileRequested', options);

    if (options.fileSystemId !== this.fileSystemId ||
        !this.openedFiles[options.openRequestId]) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    const filePath = this.openedFiles[options.openRequestId];
    const entry = this.findEntryByPath(filePath);
    if (!entry) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const metadata = entry.metadata;

    if (metadata.isDirectory) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_FAIL) {
      onError(chrome.fileSystemProvider.ProviderError.FAILED);
      return;
    }

    if (filePath === '/' + TestFileSystemProvider.FILE_DENIED) {
      onError(chrome.fileSystemProvider.ProviderError.ACCESS_DENIED);
      return;
    }

    // Writing beyond the end of the file.
    if (options.offset > metadata.size) {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const continueWrite = () => {
      // Create an array with enough space for new data.
      const prevContents = textToBuffer(entry.contents || '');
      const newLength = Math.max(
          prevContents.byteLength, options.offset + options.data.byteLength);
      const newContents = new Uint8Array(new ArrayBuffer(newLength));
      // Write existing data and new data.
      newContents.set(new Uint8Array(prevContents), 0);
      newContents.set(new Uint8Array(options.data), options.offset);
      // Save the new file as text.
      entry.contents = new TextDecoder().decode(newContents);
      metadata.size = newContents.length;
      onSuccess();
    };

    if (filePath === '/' + TestFileSystemProvider.FILE_BLOCK_IO) {
      // Block the write until it's unblocked.
      this.stallRequest('onWriteFileRequested', options).then(continueWrite);
      return;
    }

    continueWrite();
  }

  /**
   * FSP: implementation of deleting an entry within the same file system.
   *
   * @param {!chrome.fileSystemProvider.DeleteEntryRequestedOptions} options
   *  Options.
   * @param {function()} onSuccess Success callback
   * @param {function(chrome.fileSystemProvider.ProviderError)} onError Error
   *  callback with an error code.
   */
  onDeleteEntryRequested(options, onSuccess, onError) {
    if (options.fileSystemId !== this.fileSystemId) {
      onError(chrome.fileSystemProvider.ProviderError.SECURITY);
      return;
    }

    if (options.entryPath === '/') {
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const entry = this.findEntryByPath(options.entryPath);
    if (!entry) {
      onError(chrome.fileSystemProvider.ProviderError.NOT_FOUND);
      return;
    }

    if (entry.metadata.isDirectory &&
        (!options.recursive && Object.keys(entry.children).length > 0)) {
      // Don't allow non-recursive deletion of non-empty directories.
      onError(chrome.fileSystemProvider.ProviderError.INVALID_OPERATION);
      return;
    }

    const {dirPath, fileName} = splitPath(options.entryPath);
    const parentEntry = this.findEntryByPath(dirPath);
    delete parentEntry.children[fileName];

    onSuccess();
  }
};

/**
 * @type {string}
 * @const
 */
TestFileSystemProvider.DIR_WITH_ACTIONS = 'actions';

/**
 * @type {string}
 * @const
 */
TestFileSystemProvider.DIR_WITH_NO_ACTIONS = 'no-actions';

/**
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILESYSTEM_ID = 'test-fs';

/**
 * Reads, writes and add/remove watchers of this file always fail.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_FAIL = 'fail.txt';

/**
 * Reads and writes of this file fail with ACCESS_DENIED error.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_DENIED = 'denied.txt';

/**
 * Open requests on this file are blocked until they are manually unblocked.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_BLOCK_OPEN = 'block-open.txt';

/**
 * Read and write requests on this file are blocked until they are manually
 * unblocked.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_BLOCK_IO = 'block-io.txt';

/**
 * File reads return data normally (in multiple callbacks).
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_READ_SUCCESS = 'read-normal.txt';

/**
 * A file bigger than the max unsigned 32-bit value.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_BIG = 'read-big.txt';

/**
 * File read requests return more data than asked for.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_TOO_LARGE_CHUNK = 'read-too-large-chunks.txt';

/**
 * File read requests call both error and success callbacks.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_INVALID_CALLBACK = 'read-invalid-callback.txt';

/**
 * File with negative size in metadata.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_NEGATIVE_SIZE = 'negative-size.txt';

/**
 * File with invalid modification time.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_INVALID_DATE = 'invalid-date.txt';

/**
 * File with only type for metadata fields.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_ONLY_TYPE = 'metadata-only-type.txt';

/**
 * File with only size for metadata fields.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_ONLY_TYPE_AND_SIZE =
    'metadata-only-type-and-size.txt';

/**
 * File always with valid thumbnail.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.FILE_ALWAYS_VALID_THUMBNAIL =
    'always-with-thumbnail.txt';

/**
 * Initial contents of default testing files.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.INITIAL_TEXT = 'Hello world. How are you today?';

/**
 * Valid thumbnail for testing files.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.VALID_THUMBNAIL =
    'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA' +
    'AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO' +
    '9TXL0Y4OHwAAAABJRU5ErkJggg==';

/**
 * A valid action ID that will execute successfully.
 *
 * @type {string}
 * @const
 */
TestFileSystemProvider.ACTION_ID = 'test-action-id';

/**
 * @type {Array<Object>}
 * @const
 */
TestFileSystemProvider.ACTIONS = Object.freeze(
    [{id: 'SHARE'}, {id: 'SomeCustomAction', title: 'Do something custom'}]);

// Service worker entry point.
export function serviceWorkerMain(serviceWorker) {
  const provider =
      new TestFileSystemProvider(TestFileSystemProvider.FILESYSTEM_ID);

  provider.setUpProviderListeners();
  provider.setUpCommandListener();
}