chromium/chrome/test/data/extensions/api_test/file_system_provider/service_worker/helpers.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 {TestFileSystemProvider} from '/_test_resources/api_test/file_system_provider/service_worker/provider.js';

/** A blocking queue implementation.  */
export class Queue {
  constructor() {
    /**
     * Items currently in the queue.
     * @private {!Array<!Object>}
     */
    this.items_ = [];
    /**
     * Readers waiting for an item to be pushed to the queue.
     * @private {!Array<function(!Object)>}
     */
    this.readers = [];
  }

  /**
   * Pushes an item into the queue and unblocks the first waiting reader if
   * there are any. This method returns immediately and will never block.
   *
   * @param {!Object} item
   */
  push(item) {
    if (this.readers.length > 0) {
      this.readers.shift()(item);
      return;
    }
    this.items_.push(item);
  }

  /**
   * Pops the first item from the queue. If the queue is empty, will wait until
   * an item is available.
   *
   * @returns {!Object}
   */
  async pop() {
    if (this.items_.length > 0) {
      return this.items_.shift();
    }
    return new Promise(resolve => {
      this.readers.push(resolve);
    });
  }

  clear() {
    this.items_ = [];
  }

  /** @returns {number} */
  size() {
    return this.items_.length;
  }
};


/**
 * @param {function(...?)} fn
 * @param {...?} args
 * @returns {!Promise<?>}
 */
export async function promisifyWithLastError(fn, ...args) {
  return new Promise((resolve, reject) => {
    fn(...args, (result) => {
      const error = chrome.runtime.lastError;
      if (error) {
        reject(new Error(error.message));
      } else {
        resolve(result);
      }
    });
  });
}

/**
 * Catch error thrown in an async function.
 *
 * @param {!Promise<?>} promise
 * @returns {?Object} thrown error, or null if the function returns
 *    successfully. Function's return value is discarded.
 */
export async function catchError(promise) {
  try {
    await promise;
    return null;
  } catch (e) {
    return e;
  }
}

/**
 * @param {!FileEntry} fileEntry
 * @returns {!Promise<!FileWriter>}
 */
 export async function createWriter(fileEntry) {
  return new Promise(
      (resolve, reject) => fileEntry.createWriter(resolve, reject));
}

/**
 * Gets volume information for the provided file system.
 *
 * @param {string} fileSystemId Id of the provided file system.
 * @returns {!Promise<?chrome.fileManagerPrivate.VolumeMetadata>} volume
 *     information object in case of success, or null if
 *    not found.
 */
export async function getVolumeInfo(fileSystemId) {
  const volumeList = await promisifyWithLastError(
      chrome.fileManagerPrivate.getVolumeMetadataList);
  for (const volume of volumeList) {
    if (volume.providerId === chrome.runtime.id &&
        volume.fileSystemId === fileSystemId &&
        volume.diskFileSystemType !== 'fusebox') {
      return volume;
    }
  }
  throw new Error(`volume not found: ${fileSystemId}`);
};

/**
 * Wrappers for chrome.fileSystemProvider.* are still needed for Closure to
 * work, as it's not aware they are returning promises if callbacks are omitted.
 * @returns {!Promise<!Array<!chrome.fileSystemProvider.FileSystemInfo>>}
 * @suppress {checkTypes}
 */
export async function getAllFsInfos() {
  return chrome.fileSystemProvider.getAll();
}

/**
 * @param {!FileEntry|!DirectoryEntry} entry
 * @returns {!Promise<!Metadata>}
 */
 export async function getMetadata(entry) {
  return new Promise((resolve, reject) => entry.getMetadata(resolve, reject));
}

/**
 * Async wrapper.
 * @param {string} fileSystemId
 * @returns {!Promise<void>}
 * @suppress {checkTypes}
 */
export async function unmount(fileSystemId) {
  return chrome.fileSystemProvider.unmount({fileSystemId});
}

/**
 * Async wrapper.
 * @param {string} fileSystemId
 * @returns {!Promise<!chrome.fileSystemProvider.FileSystemInfo>}
 * @suppress {checkTypes}
 */
export async function getFsInfoById(fileSystemId) {
  return chrome.fileSystemProvider.get(fileSystemId);
}

/**
 * @param {{
 *    openedFilesLimit: (number|undefined),
 *    supportsNotifyTag:(boolean|undefined)
 * }=} optionsOverride
 * @returns {!Promise<{fileSystem: !FileSystem, volumeInfo:
 *     !chrome.fileManagerPrivate.VolumeMetadata}>} information about the
 *     mounted filesystem instance.
 */
async function mount(optionsOverride) {
  const options = {
    fileSystemId: TestFileSystemProvider.FILESYSTEM_ID,
    displayName: 'Test Filesystem',
    writable: true,
    ...optionsOverride,
  };
  await promisifyWithLastError(chrome.fileSystemProvider.mount, options);
  const volumeInfo = await getVolumeInfo(options.fileSystemId);
  if (!volumeInfo) {
    throw new Error(`volume not found for filesystem: ${options.fileSystemId}`);
  }
  const fileSystem = await promisifyWithLastError(
      chrome.fileSystem.requestFileSystem,
      {volumeId: volumeInfo.volumeId, writable: true});
  if (!fileSystem) {
    throw new Error(`filesystem not found for volume: ${volumeInfo.volumeId}`);
  }
  return {fileSystem, volumeInfo};
};

/**
 * @param {!FileEntry} fileEntry
 * @returns {!Promise<!File>}
 */
export async function openFile(fileEntry) {
  return new Promise((resolve, reject) => fileEntry.file(resolve, reject));
}

/**
 * @param {!Blob} blob
 * @returns {!Promise<string>}
 */
export async function readTextFromBlob(blob) {
  const {promise} = startReadTextFromBlob(blob);
  return promise;
}

/**
 * @param {!Blob} blob
 * @returns {{promise: !Promise<string>, reader: !FileReader}}
 */
export function startReadTextFromBlob(blob) {
  const reader = new FileReader();
  const promise = new Promise((resolve, reject) => {
    reader.onload = e => resolve(reader.result);
    reader.onerror = e => reject(reader.error);
    reader.onabort = e => reject(reader.error);
    reader.readAsText(blob);
  });
  return {reader, promise};
}

export class MountedTestFileSystem {
  /**
   * @param {!FileSystem} fileSystem
   * @param {!chrome.fileManagerPrivate.VolumeMetadata} volumeInfo
   */
  constructor(fileSystem, volumeInfo) {
    this.fileSystem = fileSystem;
    this.volumeInfo = volumeInfo;
  }

  /**
   * Unmount and mount the test filesystem with a new open file limit.
   *
   * @param {number} openedFilesLimit
   */
  async remount(openedFilesLimit) {
    await promisifyWithLastError(chrome.fileSystemProvider.unmount, {
      fileSystemId: TestFileSystemProvider.FILESYSTEM_ID,
    });
    const {fileSystem, volumeInfo} = await mount({openedFilesLimit});
    this.fileSystem = fileSystem;
    this.volumeInfo = volumeInfo;
  }

  /**
   * Get a file entry from the root of the mounted filesystem.
   *
   * @param {string} path
   * @param {{create: (boolean|undefined), exclusive: (boolean|undefined)}}
   *     options
   * @returns {!Promise<!FileEntry>}
   */
  async getFileEntry(path, options) {
    return new Promise(
        (resolve, reject) =>
            this.fileSystem.root.getFile(path, options, resolve, reject));
  }

  /**
   * Get a directory entry from the root of the mounted filesystem.
   *
   * @param {string} path
   * @param {{create: (boolean|undefined), exclusive: (boolean|undefined)}}
   *  options
   * @returns {!Promise<!DirectoryEntry>}
   */
  async getDirectoryEntry(path, options) {
    return new Promise(
        (resolve, reject) =>
            this.fileSystem.root.getDirectory(path, options, resolve, reject));
  }
};

/**
 * Create a mounted test filesystem instance.
 *
 * @param {{
 *    openedFilesLimit: (number|undefined),
 *    supportsNotifyTag: (boolean|undefined)
 * }=} optionsOverride
 * @return {!Promise<!MountedTestFileSystem>}
 */
export async function mountTestFileSystem(optionsOverride) {
  const {fileSystem, volumeInfo} = await mount(optionsOverride);
  return new MountedTestFileSystem(fileSystem, volumeInfo);
}

/**
 * A proxy to a FileSystemProvider instance running in a different context (or
 * different extension) to be called from test code. All the calls and arguments
 * are forwarded as is to the test provider, see corresponding functions in
 * FileSystemProvider for descriptions.
 */
export class ProviderProxy {
  constructor(extensionId) {
    /**
     * Target extension ID to send messages to.
     *
     * @private {string}
     */
    this.extensionId_ = extensionId;
  }

  /**
   * @param {!Object<string, !Object>} files
   * @returns {!Promise<void>}
   */
  async addFiles(files) {
    return this.callProvider('addFiles', files);
  }

  /**
   * @param {number} requestId
   * @returns {!Promise<void>}
   */
  async continueRequest(requestId) {
    return this.callProvider('continueRequest', requestId);
  }

  /**
   * @param {string} eventName
   * @returns {!Promise<number>}
   */
  async getEventCount(eventName) {
    return this.callProvider('getEventCount', eventName);
  }

  /**
   * @param {string} filePath
   * @returns {!Promise<string>}
   */
  async getFileContents(filePath) {
    return this.callProvider('getFileContents', filePath);
  }

  /**
   * @returns {!Promise<number>}
   */
  async getOpenedFiles() {
    return this.callProvider('getOpenedFiles');
  }

  /**
   * @param {string} entryPath
   * @param {boolean} recursive
   * @param {string} tag
   */
  async triggerNotify(entryPath, recursive, tag) {
    return this.callProvider('triggerNotify', entryPath, recursive, tag);
  }

  /**
   * @param {string} url
   * @returns {!Promise<number>}
   */
  async openTab(url) {
    return this.callProvider('openTab', url);
  }

  /** @param {number} tabId */
  async closeTab(tabId) {
    return this.callProvider('closeTab', tabId);
  }

  /**
   * @param {string} url
   * @returns {!Promise<number>}
   */
  async openWindow(url) {
    return this.callProvider('openWindow', url);
  }

  /**
   * @param {string} key
   * @param {?} value
   */
  async setConfig(key, value) {
    return this.callProvider('setConfig', key, value);
  }

  /**
   * @param {string} handlerName
   * @param {boolean} enabled
   */
  async setHandlerEnabled(handlerName, enabled) {
    return this.callProvider('setHandlerEnabled', handlerName, enabled);
  }

  /**
   * @returns {!Promise<void>}
   */
  async resetState() {
    return this.callProvider('resetState');
  }

  /**
   * @param {string} funcName
   * @returns {!Promise<!Object>}
   */
  async waitForEvent(funcName) {
    return this.callProvider('waitForEvent', funcName);
  }

  async callProvider(commandId, ...args) {
    const {response, error} = await promisifyWithLastError(
        chrome.runtime.sendMessage, this.extensionId_, {commandId, args},
        /*options=*/ {});
    if (error) {
      throw new Error(`service worker returned: ${error}`);
    }
    return response;
  }
};

/**
 * Default provider proxy: sends messages to the same extension (but could still
 * be in a different context, i.e. foreground page vs service worker).
 */
export const remoteProvider = new ProviderProxy(chrome.runtime.id);