chromium/ui/file_manager/integration_tests/file_manager/file_transfer_connector.ts

// 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 {addEntries, ENTRIES, EntryType, RootPath, sendTestMessage, TestEntryInfo} from '../test_util.js';

import {remoteCall} from './background.js';
import {DirectoryTreePageObject} from './page_objects/directory_tree.js';

interface TransferLocationOptions {
  volumeName: string;
  breadcrumbsPath: string;
  enterpriseConnectorsVolumeIdentifier: string;
}
/**
 * Info for the source or destination of a transfer.
 */
class TransferLocationInfo {
  /**
   * The volume type (e.g. downloads, drive, drive_recent,
   * drive_shared_with_me, drive_offline) or team drive name.
   */
  volumeName: string;
  breadcrumbsPath: string;
  /**
   * Identifies the volume and can be used for the
   * OnFileTransferEnterpriseConnector policy. This should match the allowed
   * policy values in policy_templates.json or
   * source_destination_matcher_ash.cc.
   */
  enterpriseConnectorsVolumeIdentifier: string;

  constructor(opts: TransferLocationOptions) {
    this.volumeName = opts.volumeName;
    this.breadcrumbsPath = opts.breadcrumbsPath;
    this.enterpriseConnectorsVolumeIdentifier =
        opts.enterpriseConnectorsVolumeIdentifier;
  }
}

interface TransferInfoOptions {
  source: TransferLocationInfo;
  destination: TransferLocationInfo;
  isMove?: boolean;
  proceedOnWarning?: boolean;
}

/**
 * Info for the transfer operation.
 */
class TransferInfo {
  source: TransferLocationInfo;
  destination: TransferLocationInfo;
  /**
   * True if this transfer is for a move operation, false for a copy
   * operation.
   */
  isMove: boolean;
  /**
   * Whether to proceed a potential warning or cancel the transfer.
   */
  proceedOnWarning: boolean;

  constructor(opts: TransferInfoOptions) {
    this.source = opts.source;
    this.destination = opts.destination;
    this.isMove = opts.isMove || false;
    this.proceedOnWarning = opts.proceedOnWarning || false;
  }
}

/**
 * Flat connector entry test set that does not include any directories.
 *
 * If a file should be blocked, name it "*blocked*".
 * If a file is allowed, name it "*allowed*".
 */
const CONNECTOR_ENTRIES_FLAT = [
  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'a_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'a_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'b_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'b_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'c_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'c_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),
];

/**
 * Flat connector entry test set that does not include any directories.
 *
 * If a file should be blocked, name it "*blocked*".
 * If a file should be warned, name it "*warned*".
 * If a file is allowed, name it "*allowed*".
 */
const CONNECTOR_ENTRIES_FLAT_WARNED = [
  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'a_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'a_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'b_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'b_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'c_warned.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'c_warned.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'd_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'd_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),
];

/**
 * Test set to test deep scanninng, contains nested directories.
 *
 * If a file should be blocked, name it "*blocked*".
 * If a file is allowed, name it "*allowed*".
 * If a directory only contains allowed files, name it "*allowed*".
 */
const CONNECTOR_ENTRIES_DEEP = [
  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'A',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A/B_allowed',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'B_allowed',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A/C',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'C',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/B_allowed/g_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'g_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/i_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'i_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/j_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'j_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/k_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'k_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),
];

/**
 * Test set to test deep scanninng, contains nested directories.
 *
 * If a file should be blocked, name it "*blocked*".
 * If a file is allowed, name it "*allowed*".
 * If a directory only contains allowed files, name it "*allowed*".
 */
const CONNECTOR_ENTRIES_DEEP_WARNED = [
  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'A',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A/B_allowed',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'B_allowed',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.DIRECTORY,
    targetPath: 'A/C',
    lastModifiedTime: 'Jan 1, 2000, 1:00 AM',
    nameText: 'C',
    sizeText: '--',
    typeText: 'Folder',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/B_allowed/g_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'g_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/B_allowed/h_warned.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'h_warned.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/i_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'i_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/j_allowed.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'j_allowed.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/k_blocked.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'k_blocked.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),

  new TestEntryInfo({
    type: EntryType.FILE,
    targetPath: 'A/C/l_warned.jpg',
    sourceFileName: 'small.jpg',
    mimeType: 'image/jpeg',
    lastModifiedTime: 'Jan 18, 2038, 1:02 AM',
    nameText: 'l_warned.jpg',
    sizeText: '886 bytes',
    typeText: 'JPEG image',
  }),
];

/**
 * A list of transfer locations, for use with transferBetweenVolumes.
 * volumeName has to match an entry of
 * AddEntriesMessage::MapStringToTargetVolume().
 */
const TRANSFER_LOCATIONS = {
  drive: new TransferLocationInfo({
    breadcrumbsPath: '/My Drive',
    volumeName: 'drive',
    enterpriseConnectorsVolumeIdentifier: 'GOOGLE_DRIVE',
  }),

  downloads: new TransferLocationInfo({
    breadcrumbsPath: '/My files/Downloads',
    volumeName: 'local',
    enterpriseConnectorsVolumeIdentifier: 'MY_FILES',
  }),

  crostini: new TransferLocationInfo({
    breadcrumbsPath: '/My files/Linux files',
    volumeName: 'crostini',
    enterpriseConnectorsVolumeIdentifier: 'CROSTINI',
  }),

  usb: new TransferLocationInfo({
    breadcrumbsPath: '/fake-usb',
    volumeName: 'usb',
    enterpriseConnectorsVolumeIdentifier: 'REMOVABLE',
  }),

  smbfs: new TransferLocationInfo({
    breadcrumbsPath: '/SMB Share',
    volumeName: 'smbfs',
    enterpriseConnectorsVolumeIdentifier: 'SMB',
  }),

  mtp: new TransferLocationInfo({
    breadcrumbsPath: '/fake-mtp',
    volumeName: 'mtp',
    enterpriseConnectorsVolumeIdentifier: 'DEVICE_MEDIA_STORAGE',
  }),

  android_files: new TransferLocationInfo({
    breadcrumbsPath: '/My files/Play files',
    volumeName: 'android_files',
    enterpriseConnectorsVolumeIdentifier: 'ARC',
  }),
};
Object.freeze(TRANSFER_LOCATIONS);

// TODO(crbug.com/1361898): Remove these ones proper error details are
// displayed.
const OLD_COPY_FAIL_MESSAGE =
    'Copy operation failed. The file could not be accessed ' +
    'for security reasons.';
const OLD_MOVE_FAIL_DIRECTORY_MESSAGE =
    `Can't move file. The file could not be modified.`;
const OLD_MOVE_FAIL_FILE_MESSAGE =
    `Can't move file. The file could not be accessed ` +
    'for security reasons.';

const NEW_COPY_FAIL_MESSAGE = 'File blocked from copying';
const NEW_MOVE_FAIL_MESSAGE = `File blocked from moving`;
const TWO_FILES_COPY_FAIL_MESSAGE = '2 files blocked from copying';
const TWO_FILES_MOVE_FAIL_MESSAGE = '2 files blocked from moving';
const SINGLE_FILE_WARN_MESSAGE = 'c_warned.jpg may contain sensitive content';
const TWO_FILES_WARN_MESSAGE = '2 files may contain sensitive content';

const COPY_OUT_OF_SPACE_ERROR_MESSAGE =
    'Copy operation failed. There is not enough space.';

/**
 * Opens a Files app's main window and creates the source and destination
 * entries.
 * @param transferInfo Options for the transfer.
 * @return Promise to be fulfilled with the window ID.
 */
async function setupForFileTransferConnector(
    transferInfo: TransferInfo, srcContents: TestEntryInfo[],
    dstContents: TestEntryInfo[]): Promise<string> {
  const sourceEntriesPromise =
      addEntries([transferInfo.source.volumeName], srcContents);
  const destEntriesPromise =
      addEntries([transferInfo.destination.volumeName], dstContents);

  const appId = await remoteCall.openNewWindow(RootPath.DOWNLOADS, {});
  await remoteCall.waitForElement(appId, '#detail-table');

  // Wait until the elements are loaded in the table.
  await Promise.all([
    remoteCall.waitForFileListChange(appId, 0),
    sourceEntriesPromise,
    destEntriesPromise,
  ]);
  await remoteCall.waitFor('isFileManagerLoaded', appId, true);
  return appId;
}

/**
 * Returns all entries that are children of the passed directory.
 * @param entries The entries.
 * @param directory The directory path. Contains the path of the current
 *     directory, e.g., ["A", "B"] for A/B/.
 */
function getCurrentEntries(entries: TestEntryInfo[], directory: string[]) {
  return entries.filter(entry => {
    const parent = entry.targetPath.split('/').slice(0, -1);
    return parent.length === directory.length &&
        parent.every((value, index) => value === directory[index]);
  });
}

/**
 * Verifies the recursive contents of the current path by checking the file list
 * of the current path and its ancestors.
 * @param appId App window Id.
 * @param expectedEntries Expected contents of file list.
 * @param rootDirectory The path to the root directory for the check.
 * @param currentSubDirectory The current directory path split at '/', e.g.,
 *     ["A", "B"] for A/B/.
 */
async function verifyDirectoryRecursively(
    appId: string, expectedEntries: TestEntryInfo[], rootDirectory: string,
    currentSubDirectory: string[] = []) {
  // 1. Check current directory.
  const currentEntries =
      getCurrentEntries(expectedEntries, currentSubDirectory);
  await remoteCall.waitForFiles(
      appId, TestEntryInfo.getExpectedRows(currentEntries),
      {ignoreLastModifiedTime: true});

  // 2. For each subdirectory: enter subdirectory and call recursion.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  for (const entry of currentEntries.filter(
           entry => entry.type === EntryType.DIRECTORY)) {
    currentSubDirectory.push(entry.nameText);
    await directoryTree.navigateToPath(
        rootDirectory + '/' + currentSubDirectory.join('/'));
    await verifyDirectoryRecursively(
        appId, expectedEntries, rootDirectory, currentSubDirectory);
    currentSubDirectory.pop();
  }

  // 3. After the recursion ends, navigate back to the root directory.
  if (currentSubDirectory.length === 0) {
    // Go back to the root directory.
    await directoryTree.navigateToPath(rootDirectory);
  }
}

/**
 * Function to toggle display of all play files.
 * Before this function is called, the play file folder has to be opened.
 * @param appId App window Id.
 */
async function showAllPlayFiles(appId: string) {
  const toggleMenuItemSelector = '#gear-menu-toggle-hidden-android-folders';

  // Open the gear menu by clicking the gear button.
  await remoteCall.waitAndClickElement(appId, '#gear-button:not([hidden])');

  // Wait for the gear-menu to appear and click the menu item.
  await remoteCall.waitAndClickElement(
      appId,
      `#gear-menu:not([hidden]) ${
          toggleMenuItemSelector}:not([disabled]):not([checked])`);

  // Wait for item to be checked.
  await remoteCall.waitForElement(appId, toggleMenuItemSelector + '[checked]');
}

/**
 * Checks that the panel item's primary and secondary buttons have expected type
 * and text, and then clicks the button defined by selectedButton.
 * @param appId ID of the Files app window.
 * @param secondaryButtonCategory Expected secondary button category (dismiss or
 *     cancel).
 * @param selectedButton The button to click (primary or secondary).
 */
async function verifyPanelButtonsAndClick(
    appId: string, secondaryButtonCategory: string, selectedButton: string) {
  const primaryButton = await remoteCall.waitForElement(
      appId, ['#progress-panel', 'xf-panel-item', 'xf-button#primary-action']);
  chrome.test.assertEq(
      'extra-button', primaryButton.attributes['data-category']);

  const secondaryButton = await remoteCall.waitForElement(
      appId,
      ['#progress-panel', 'xf-panel-item', 'xf-button#secondary-action']);
  chrome.test.assertEq(
      secondaryButtonCategory, secondaryButton.attributes['data-category']);

  await remoteCall.waitAndClickElement(appId, [
    '#progress-panel',
    'xf-panel-item',
    `xf-button#${selectedButton}-action`,
  ]);
}

/**
 * Test function to copy from the specified source to the specified destination.
 * @param transferInfo Options for the transfer.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 * @param expectedFinalMsg The final message to expect at the progress center.
 * @param expectedWarnMsg The warning message to expect at the progress center.
 */
async function transferBetweenVolumes(
    transferInfo: TransferInfo, entryTestSet: TestEntryInfo[],
    expectedFinalMsg: string, expectedWarnMsg: string = '') {
  await setupVolumes(transferInfo);

  // Setup policy.
  await sendTestMessage({
    name: 'setupFileTransferPolicy',
    source: transferInfo.source.enterpriseConnectorsVolumeIdentifier,
    destination: transferInfo.destination.enterpriseConnectorsVolumeIdentifier,
  });

  // Setup reporting expectations.
  await sendTestMessage({
    name: 'expectFileTransferReports',
    source_volume: transferInfo.source.enterpriseConnectorsVolumeIdentifier,
    destination_volume:
        transferInfo.destination.enterpriseConnectorsVolumeIdentifier,
    entry_paths: entryTestSet.filter(entry => entry.type === EntryType.FILE)
                     .map(entry => entry.targetPath),
  });

  // Setup the scanning closure to be able to wait for the scanning to be
  // complete.
  await sendTestMessage({
    name: 'setupScanningRunLoop',
    number_of_expected_delegates:
        entryTestSet.filter(entry => !entry.targetPath.includes('/')).length,
  });

  const appId = await openFilesAppAndInitTransfer(transferInfo, entryTestSet);

  const reportOnly =
      await sendTestMessage({name: 'isReportOnlyFileTransferConnector'}) ===
      'true';
  if (reportOnly) {
    await verifyAfterPasteReportOnly(appId, transferInfo, entryTestSet);
  } else {
    await verifyAfterPasteBlocking(
        appId, transferInfo, entryTestSet, expectedFinalMsg, expectedWarnMsg);
  }
}

/**
 * Test function to copy from the specified source to the specified destination.
 * @param transferInfo Options for the transfer.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 * @param expectedFinalMsg The final message to expect at the progress center.
 */
async function transferBetweenVolumesNoSpace(
    transferInfo: TransferInfo, entryTestSet: TestEntryInfo[],
    expectedFinalMsg: string) {
  // Ensure reportOnly is set, as the no-space behavior is special for
  // report-only mode.
  const reportOnly =
      await sendTestMessage({name: 'isReportOnlyFileTransferConnector'}) ===
      'true';
  chrome.test.assertTrue(reportOnly);


  await setupVolumes(transferInfo);

  // Setup policy.
  await sendTestMessage({
    name: 'setupFileTransferPolicy',
    source: transferInfo.source.enterpriseConnectorsVolumeIdentifier,
    destination: transferInfo.destination.enterpriseConnectorsVolumeIdentifier,
  });

  await sendTestMessage({
    name: 'mockIOTaskDestinationNoSpace',
  });

  // Setup the scanning closure to be able to wait for the scanning to be
  // complete. There should only be one delegate, as all other delegates aren't
  // initiated when an out of space error occurs.
  await sendTestMessage({
    name: 'setupScanningRunLoop',
    number_of_expected_delegates: 1,
  });

  const appId = await openFilesAppAndInitTransfer(transferInfo, entryTestSet);

  await verifyAfterPasteReportOnlyNoSpace(
      appId, transferInfo, expectedFinalMsg, entryTestSet);
}

/**
 * Mounts required volumes.
 * @param transferInfo Options for the transfer.
 */
async function setupVolumes(transferInfo: TransferInfo) {
  if (transferInfo.source.volumeName === 'usb' ||
      transferInfo.destination.volumeName === 'usb') {
    await sendTestMessage({name: 'mountFakeUsbEmpty'});
  }
  if (transferInfo.source.volumeName === 'smbfs' ||
      transferInfo.destination.volumeName === 'smbfs') {
    await sendTestMessage({name: 'mountSmbfs'});
  }
  if (transferInfo.source.volumeName === 'mtp' ||
      transferInfo.destination.volumeName === 'mtp') {
    await sendTestMessage({name: 'mountFakeMtpEmpty'});
  }
}

/**
 * Opens Files app and initiates source with entryTestSet and destination
 * with [ENTRIES.hello].
 * The destination is populated to prevent flakes (we can wait for the `hello`
 * file to appear).
 * @param transferInfo Options for the transfer.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 */
async function openFilesAppAndInitTransfer(
    transferInfo: TransferInfo, entryTestSet: TestEntryInfo[]) {
  const dstContents = TestEntryInfo.getExpectedRows([ENTRIES.hello]);

  const appId = await setupForFileTransferConnector(
      transferInfo, entryTestSet, [ENTRIES.hello]);

  // Select the source folder.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.navigateToPath(transferInfo.source.breadcrumbsPath);

  if (transferInfo.source.volumeName === 'android_files') {
    await showAllPlayFiles(appId);
  }

  // Wait for the expected files to appear in the file list.
  await remoteCall.waitForFiles(
      appId, TestEntryInfo.getExpectedRows(getCurrentEntries(entryTestSet, [])),
      {ignoreLastModifiedTime: true});

  // Focus the file list.
  await remoteCall.focus(appId, ['#file-list:not([hidden])']);

  // Select all files.
  const ctrlA = ['#file-list', 'a', true, false, false] as const;
  await remoteCall.fakeKeyDown(appId, ...ctrlA);
  // Check: the file-list should be selected.
  await remoteCall.waitForElement(appId, '#file-list li[selected]');

  // Copy the files. Similar to (ctrl + c) or (ctrl + x).
  // The actual copy only starts with the paste (ctrl + v).
  const transferCommand = transferInfo.isMove ? 'cut' : 'copy';
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'execCommand', appId, [transferCommand]));

  // Select the destination folder.
  await directoryTree.navigateToPath(transferInfo.destination.breadcrumbsPath);

  // Wait for the initially expected files to appear in the file list.
  // This is before the actual copy!
  await remoteCall.waitForFiles(
      appId, dstContents, {ignoreFileSize: true, ignoreLastModifiedTime: true});
  // Paste the file. Similar to (ctrl + v).
  // This will execute the actual paste.
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('execCommand', appId, ['paste']));
  return appId;
}

/**
 * Verify what happens after a paste when scanning can block files.
 *
 * @param appId The app id of the files app window.
 * @param transferInfo Options for the transfer.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 * @param expectedFinalMsg The final message to expect at the progress center.
 * @param expectedWarnMsg The warning message to expect at the progress center.
 */
async function verifyAfterPasteBlocking(
    appId: string, transferInfo: TransferInfo, entryTestSet: TestEntryInfo[],
    expectedFinalMsg: string, expectedWarnMsg: string) {
  // Check that a scanning label is shown.
  await remoteCall.waitForFeedbackPanelItem(
      appId,
      transferInfo.isMove ? new RegExp('^Moving.*$') :
                            new RegExp('^Copying.*$'),
      new RegExp('^Checking.*$'));

  // After the scanning label is shown, we resume the transfer.
  // Issue the responses, s.t., the transfer can continue.
  await sendTestMessage({name: 'issueFileTransferResponses'});

  const usesNewFileTransferConnectorUI =
      await sendTestMessage({name: 'usesNewFileTransferConnectorUI'}) ===
      'true';

  const expectedNumberOfWarnedFilesByConnectors = Number(await sendTestMessage(
      {name: 'getExpectedNumberOfWarnedFilesByConnectors'}));

  const bypassRequireJustification =
      await sendTestMessage({name: 'doesBypassRequireJustification'}) ===
      'true';

  const directoryTree = await DirectoryTreePageObject.create(appId);

  if (usesNewFileTransferConnectorUI &&
      expectedNumberOfWarnedFilesByConnectors > 0) {
    // Check that the warning appears in the feedback panel.
    await remoteCall.waitForFeedbackPanelItem(
        appId,
        transferInfo.isMove ? new RegExp('^Review is required before moving$') :
                              new RegExp('^Review is required before copying$'),
        new RegExp(`^${expectedWarnMsg}$`));

    if (transferInfo.proceedOnWarning) {
      // Expect warning proceeded messages.
      await sendTestMessage({
        name: 'expectFileTransferReports',
        source_volume: transferInfo.source.enterpriseConnectorsVolumeIdentifier,
        destination_volume:
            transferInfo.destination.enterpriseConnectorsVolumeIdentifier,
        entry_paths: entryTestSet.filter(entry => entry.type === EntryType.FILE)
                         .map(entry => entry.targetPath),
        expect_proceed_warning_reports: true,
      });

      // Proceed the warning (single file warning without user justification
      // required) / open the warning dialog (multiple file warning or user
      // justification required).
      await verifyPanelButtonsAndClick(appId, 'cancel', 'primary');

      if (expectedNumberOfWarnedFilesByConnectors > 1 ||
          bypassRequireJustification) {
        await sendTestMessage({
          name: 'verifyFileTransferWarningDialogAndProceed',
          app_id: appId,
        });
      }
    } else {
      // Cancel the warning by pressing on the secondary button.
      await verifyPanelButtonsAndClick(appId, 'cancel', 'secondary');

      // Wait 500ms to ensure files aren't moved.
      await new Promise(r => setTimeout(r, 500));

      // Ensure progress panel item is gone.
      await remoteCall.waitForElementLost(
          appId, ['#progress-panel', 'xf-panel-item']);

      // Wait for the expected files to appear in the file list.

      // No file should be transferred, so there should be no new file at the
      // destination.
      const expectedEntries = [ENTRIES.hello];
      await verifyDirectoryRecursively(
          appId, expectedEntries, transferInfo.destination.breadcrumbsPath);

      // All files should still exist at the destination.
      await directoryTree.navigateToPath(transferInfo.source.breadcrumbsPath);
      const expectedSourceEntries = entryTestSet;
      // Wait for the expected files to appear in the file list.
      await verifyDirectoryRecursively(
          appId, expectedSourceEntries, transferInfo.source.breadcrumbsPath);

      // If the warning is cancelled, the transfer is also cancelled, so do not
      // perform any further checks, as there will be no further notifications,
      // etc.
      return;
    }
  }

  // Wait for the expected files to appear in the file list.
  // Files marked as 'blocked' should not appear.
  const expectedEntries =
      entryTestSet.concat([ENTRIES.hello])
          .filter(entry => !entry.targetPath.includes('blocked'));
  await verifyDirectoryRecursively(
      appId, expectedEntries, transferInfo.destination.breadcrumbsPath);

  // Verify contents of the source directory.
  await directoryTree.navigateToPath(transferInfo.source.breadcrumbsPath);
  let expectedSourceEntries = entryTestSet;
  if (transferInfo.isMove) {
    // For a move, paths that include "allowed" should not be present at the
    // source.
    expectedSourceEntries = expectedSourceEntries.filter(
        entry => !entry.targetPath.includes('allowed'));
  }
  // Wait for the expected files to appear in the file list.
  await verifyDirectoryRecursively(
      appId, expectedSourceEntries, transferInfo.source.breadcrumbsPath);

  // Check that the error appears in the feedback panel.
  const expectedNumberOfBlockedFilesByConnectors = Number(await sendTestMessage(
      {name: 'getExpectedNumberOfBlockedFilesByConnectors'}));
  if (usesNewFileTransferConnectorUI &&
      expectedNumberOfBlockedFilesByConnectors > 1) {
    // There should be a review button if there are at least two errors.
    await remoteCall.waitForFeedbackPanelItem(
        appId, new RegExp(`^${expectedFinalMsg}$`),
        new RegExp('^Review for further details$'));

    await verifyPanelButtonsAndClick(appId, 'dismiss', 'primary');
    await sendTestMessage({
      name: 'verifyFileTransferErrorDialogAndDismiss',
      app_id: appId,
    });
  } else if (usesNewFileTransferConnectorUI) {
    // For a single file error, this should show an error reason as secondary
    // text.
    await remoteCall.waitForFeedbackPanelItem(
        appId, new RegExp(`^${expectedFinalMsg}$`),
        new RegExp('was blocked because of content$'));

  } else {
    // Check that only one line of text is shown.
    await remoteCall.waitForFeedbackPanelItem(
        appId, new RegExp(`^${expectedFinalMsg}$`), new RegExp(`^$`));
  }
}

/**
 * Verify what happens after a paste in the case of report-only scans.
 *
 * @param appId The app id of the files app window.
 * @param transferInfo Options for the transfer.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 */
async function verifyAfterPasteReportOnly(
    appId: string, transferInfo: TransferInfo, entryTestSet: TestEntryInfo[]) {
  // No check for scanning label, as there shouldn't be one.

  // Wait for the expected files to appear in the file list.
  // All files should appear, even those marked as 'blocked'.
  const expectedEntries = entryTestSet.concat([ENTRIES.hello]);
  await verifyDirectoryRecursively(
      appId, expectedEntries, transferInfo.destination.breadcrumbsPath);

  // Verify contents of the source directory.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.navigateToPath(transferInfo.source.breadcrumbsPath);
  let expectedSourceEntries = entryTestSet;
  if (transferInfo.isMove) {
    // For a move, the source directory should be empty.
    expectedSourceEntries = [];
  }
  // Wait for the expected files to appear in the file list.
  await verifyDirectoryRecursively(
      appId, expectedSourceEntries, transferInfo.source.breadcrumbsPath);

  // Check that the status panel automatically vanishes.
  // This means that there was no error.
  await remoteCall.waitForElementLost(
      appId, ['#progress-panel', 'xf-panel-item']);

  // After the transfer completed, we issue scanning responses.
  // This ensures that scanning does not impact the transfer.
  await sendTestMessage({name: 'issueFileTransferResponses'});

  // We have to wait for the scanning to be completed to fulfill the report
  // expectations.
  await sendTestMessage({name: 'waitForFileTransferScanningToComplete'});
}

/**
 * Verify what happens after a paste in the case of report-only scans if
 * there's a no space error.
 *
 * @param appId The app id of the files app window.
 * @param transferInfo Options for the transfer.
 * @param expectedFinalMsg The final message to expect at the progress center.
 * @param entryTestSet The set of file and directory entries to be used for the
 *     test.
 */
async function verifyAfterPasteReportOnlyNoSpace(
    appId: string, transferInfo: TransferInfo, expectedFinalMsg: string,
    entryTestSet: TestEntryInfo[]) {
  // No check for scanning label, as there shouldn't be one.

  // Wait for the expected files to appear in the file list.
  // Only the hello file should appear, as the destination is assumed to be out
  // of space.
  const expectedEntries = [ENTRIES.hello];
  await verifyDirectoryRecursively(
      appId, expectedEntries, transferInfo.destination.breadcrumbsPath);

  // Verify contents of the source directory.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.navigateToPath(transferInfo.source.breadcrumbsPath);
  // The source entries should be unchanged.
  const expectedSourceEntries = entryTestSet;
  // Wait for the expected files to appear in the file list.
  await verifyDirectoryRecursively(
      appId, expectedSourceEntries, transferInfo.source.breadcrumbsPath);

  // Check that only one line of text is shown.
  await remoteCall.waitForFeedbackPanelItem(
      appId, new RegExp(`^${expectedFinalMsg}$`), new RegExp(`^$`));

  // After the transfer completed, we issue scanning responses.
  // This ensures that scanning does not impact the transfer.
  await sendTestMessage({name: 'issueFileTransferResponses'});

  // We have to wait for the scanning to be completed to fulfill the report
  // expectations.
  await sendTestMessage({name: 'waitForFileTransferScanningToComplete'});
}

/**
 * Tests copying from android_files to Downloads.
 */
export async function transferConnectorFromAndroidFilesToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.android_files,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromAndroidFilesToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.android_files,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

/**
 * Tests copying from Crostini to Downloads.
 */
export async function transferConnectorFromCrostiniToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.crostini,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromCrostiniToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.crostini,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

/**
 * Tests copying from Drive to Downloads.
 */
export async function transferConnectorFromDriveToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.drive,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}
export async function transferConnectorFromDriveToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.drive,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

export async function
transferConnectorFromDriveToDownloadsFlatDestinationNoSpaceForReportOnly() {
  return transferBetweenVolumesNoSpace(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.drive,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      COPY_OUT_OF_SPACE_ERROR_MESSAGE,
  );
}

/**
 * Tests moving from Drive to Downloads.
 */
export async function transferConnectorFromDriveToDownloadsMoveDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.drive,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_MOVE_FAIL_DIRECTORY_MESSAGE,
  );
}

export async function transferConnectorFromDriveToDownloadsMoveFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.drive,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_MOVE_FAIL_FILE_MESSAGE,
  );
}

/**
 * Tests copying from mtp to Downloads.
 */
export async function transferConnectorFromMtpToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.mtp,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}
export async function transferConnectorFromMtpToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.mtp,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

/**
 * Tests copying from smbfs to Downloads.
 */
export async function transferConnectorFromSmbfsToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.smbfs,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromSmbfsToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.smbfs,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

/**
 * Tests copying from usb to Downloads.
 */
export async function transferConnectorFromUsbToDownloadsDeep() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      OLD_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsFlat() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      OLD_COPY_FAIL_MESSAGE,
  );
}

/**
 * Tests for new UX.
 */
export async function transferConnectorFromUsbToDownloadsDeepNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_DEEP,
      TWO_FILES_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsFlatNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
      }),
      CONNECTOR_ENTRIES_FLAT,
      NEW_COPY_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsDeepMoveNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_DEEP,
      TWO_FILES_MOVE_FAIL_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsFlatMoveNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_FLAT,
      NEW_MOVE_FAIL_MESSAGE,
  );
}

export async function
transferConnectorFromUsbToDownloadsFlatWarnProceedNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        proceedOnWarning: true,
      }),
      CONNECTOR_ENTRIES_FLAT_WARNED,
      NEW_COPY_FAIL_MESSAGE,
      SINGLE_FILE_WARN_MESSAGE,
  );
}

export async function
transferConnectorFromUsbToDownloadsFlatWarnProceedWithJustificationNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        proceedOnWarning: true,
      }),
      CONNECTOR_ENTRIES_FLAT_WARNED,
      NEW_COPY_FAIL_MESSAGE,
      SINGLE_FILE_WARN_MESSAGE,
  );
}

export async function
transferConnectorFromUsbToDownloadsDeepWarnProceedNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        proceedOnWarning: true,
      }),
      CONNECTOR_ENTRIES_DEEP_WARNED,
      TWO_FILES_COPY_FAIL_MESSAGE,
      TWO_FILES_WARN_MESSAGE,
  );
}

export async function
transferConnectorFromUsbToDownloadsDeepWarnProceedWithJustificationNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        proceedOnWarning: true,
      }),
      CONNECTOR_ENTRIES_DEEP_WARNED,
      TWO_FILES_COPY_FAIL_MESSAGE,
      TWO_FILES_WARN_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsFlatWarnCancelNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_FLAT_WARNED,
      '',
      SINGLE_FILE_WARN_MESSAGE,
  );
}

export async function transferConnectorFromUsbToDownloadsDeepWarnCancelNewUX() {
  return transferBetweenVolumes(
      new TransferInfo({
        source: TRANSFER_LOCATIONS.usb,
        destination: TRANSFER_LOCATIONS.downloads,
        isMove: true,
      }),
      CONNECTOR_ENTRIES_DEEP_WARNED,
      '',
      TWO_FILES_WARN_MESSAGE,
  );
}