chromium/ui/file_manager/file_manager/foreground/js/file_transfer_controller_unittest.ts

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

import 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';

import {assertNotReached} from 'chrome://resources/ash/common/assert.js';
import type {CrInputElement} from 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {MockVolumeManager} from '../../background/js/mock_volume_manager.js';
import type {ProgressCenter} from '../../background/js/progress_center.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {crInjectTypeAndInit} from '../../common/js/cr_ui.js';
import {entriesToURLs} from '../../common/js/entry_utils.js';
import {MockDirectoryEntry, MockFileEntry, MockFileSystem} from '../../common/js/mock_entry.js';
import {VolumeType} from '../../common/js/volume_manager_types.js';
import {cacheEntries} from '../../state/ducks/all_entries.js';
import {setUpFileManagerOnWindow, setupStore} from '../../state/for_tests.js';
import {DialogType} from '../../state/state.js';
import {getFileData} from '../../state/store.js';
import type {XfTree} from '../../widgets/xf_tree.js';
import type {FilesToast} from '../elements/files_toast.js';

import {FakeFileSelectionHandler} from './fake_file_selection_handler.js';
import {FileListModel} from './file_list_model.js';
import type {FileSelectionHandler} from './file_selection.js';
import {deduplicatePath, DRAG_AND_DROP_GLOBAL_DATA, ENCRYPTED, FileTransferController, MISSING_FILE_CONTENTS, resolvePath, SOURCE_ROOT_URL, SOURCE_URLS, writeFile} from './file_transfer_controller.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import {MockMetadataModel} from './metadata/mock_metadata.js';
import {createFakeDirectoryModel} from './mock_directory_model.js';
import type {A11yAnnounce} from './ui/a11y_announce.js';
import {Command} from './ui/command.js';
import {FileGrid} from './ui/file_grid.js';
import {FileListSelectionModel} from './ui/file_list_selection_model.js';
import {FileTable} from './ui/file_table.js';
import {ListContainer, ListType} from './ui/list_container.js';

class TestFileTransferController extends FileTransferController {
  isDocumentWideEvent() {
    return super.isDocumentWideEvent_();
  }

  isDropTargetAllowed(destinationEntryURL: string) {
    return super.isDropTargetAllowed_(destinationEntryURL);
  }
}

let listContainer: ListContainer;
let fileTransferController: TestFileTransferController;
let directoryTree: XfTree;
let selectionHandler: FakeFileSelectionHandler;
let volumeManager: VolumeManager;

export function setUp() {
  setUpFileManagerOnWindow();

  // Setup page DOM.
  document.body.innerHTML = getTrustedHTML`
    <style>
    .hide {
      display: none;
    }
    </style>
    <command id="cut">
    <command id="copy">
    <div class="dialog-container">
      <div tabindex="0" id="directory-tree">
      </div>
      <div id="list-container">
        <files-spinner class="loading-indicator" hidden></files-spinner>
        <div id="detail-table">
          <list id="file-list" contextmenu="#file-context-menu" tabindex="0">
          </list>
        </div>
        <grid id="file-grid" contextmenu="#file-context-menu"
          tabindex="0" hidden>
        </grid>
        <paper-progress class="loading-indicator" hidden></paper-progress>
      </div>
      <div id="dialog">
      </div>
      <div id="test-elements">
        <input type="text" id="free-text">
        <cr-input id="test-input"></cr-input>
        <input type="button" id="button">
    </div>
  `;

  // Initialize Command with the <command>s.
  for (const command of document.querySelectorAll<Command>('command')) {
    crInjectTypeAndInit(command, Command);
  }
  // Fake confirmation callback.
  const confirmationDialog = () => Promise.resolve(true);

  // Fake ProgressCenter;
  const progressCenter = {} as unknown as ProgressCenter;

  // Fake MetadataModel.
  const metadataModel = new MockMetadataModel({}) as unknown as MetadataModel;

  // Fake DirectoryModel.
  const directoryModel = createFakeDirectoryModel();

  // Create fake VolumeManager and install webkitResolveLocalFileSystemURL.
  volumeManager = window.fileManager.volumeManager;
  window.webkitResolveLocalFileSystemURL =
      MockVolumeManager.resolveLocalFileSystemUrl.bind(null, volumeManager);


  // Fake FileSelectionHandler.
  selectionHandler = new FakeFileSelectionHandler();

  // Fake A11yAnnounce.
  const a11Messages = [];
  const a11y = {
    speakA11yMessage: (text: string) => {
      a11Messages.push(text);
    },
  } as A11yAnnounce;

  // Setup FileTable.
  const table =
      document.querySelector('#detail-table')! as unknown as FileTable;
  FileTable.decorate(
      table as unknown as HTMLElement, metadataModel, volumeManager, a11y,
      true /* fullPage */);
  const dataModel = new FileListModel(metadataModel);
  table.list.dataModel = dataModel;

  // Setup FileGrid.
  const grid = document.querySelector('#file-grid') as unknown as FileGrid;
  FileGrid.decorate(grid, metadataModel, volumeManager, a11y);

  // Setup the ListContainer and its dependencies
  listContainer = new ListContainer(
      document.querySelector<HTMLElement>('#list-container')!, table, grid,
      DialogType.FULL_PAGE);
  listContainer.dataModel = dataModel;
  listContainer.selectionModel = new FileListSelectionModel();
  listContainer.setCurrentListType(ListType.DETAIL);

  // Setup DirectoryTree elements.
  directoryTree =
      document.querySelector('#directory-tree') as unknown as XfTree;

  const filesToast =
      document.querySelector('files-toast') as unknown as FilesToast;

  // Initialize FileTransferController.
  fileTransferController = new TestFileTransferController(
      document,
      listContainer,
      directoryTree,
      confirmationDialog,
      progressCenter,
      metadataModel,
      directoryModel,
      volumeManager,
      selectionHandler as unknown as FileSelectionHandler,
      filesToast,
  );
}

/**
 * Tests isDocumentWideEvent_.
 */
export function testIsDocumentWideEvent() {
  const input = document.querySelector<HTMLInputElement>('#free-text')!;
  const crInput = document.querySelector<CrInputElement>('#test-input')!;
  const button = document.querySelector<HTMLInputElement>('#button')!;

  // Should return true when body is focused.
  document.body.focus();
  assertEquals(document.body, document.activeElement);
  assertTrue(fileTransferController.isDocumentWideEvent());

  // Should return true when button is focused.
  button.focus();
  assertEquals(button, document.activeElement);
  assertTrue(fileTransferController.isDocumentWideEvent());

  // Should return true when tree is focused.
  directoryTree.focus();
  assertEquals(directoryTree, document.activeElement);
  assertTrue(fileTransferController.isDocumentWideEvent());

  // Should return true when FileList is focused.
  listContainer.focus();
  assertEquals(listContainer.table.list, document.activeElement);
  assertTrue(fileTransferController.isDocumentWideEvent());

  // Should return true when document is focused.
  input.focus();
  assertEquals(input, document.activeElement);
  assertFalse(fileTransferController.isDocumentWideEvent());

  // Should return true when document is focused.
  crInput.focus();
  assertEquals(crInput, document.activeElement);
  assertEquals(crInput.inputElement, crInput.shadowRoot!.activeElement);
  assertFalse(fileTransferController.isDocumentWideEvent());
}

/**
 * Tests canCutOrDrag() respects non-modifiable entries like Downloads.
 */
export function testCanMoveDownloads() {
  // Item 1 of the volume info list should be Downloads volume type.
  assertEquals(
      VolumeType.DOWNLOADS, volumeManager.volumeInfoList.item(1).volumeType);

  // Create a downloads folder inside the item.
  const myFilesVolume = volumeManager.volumeInfoList.item(1);
  const myFilesMockFs = myFilesVolume.fileSystem as MockFileSystem;

  myFilesMockFs.populate([
    '/Downloads/',
    '/otherFolder/',
  ]);
  const downloadsEntry = myFilesMockFs.entries['/Downloads']!;
  const otherFolderEntry = myFilesMockFs.entries['/otherFolder']!;

  assertTrue(!!downloadsEntry);
  assertTrue(!!otherFolderEntry);

  // Downloads can't be cut.
  selectionHandler.updateSelection([downloadsEntry], []);
  assertFalse(fileTransferController.canCutOrDrag());

  // otherFolder can be cut.
  selectionHandler.updateSelection([otherFolderEntry], []);
  assertTrue(fileTransferController.canCutOrDrag());
}

/**
 * Tests preparePaste() with FilesApp fs/sources and standard DataTransfer.
 */
export async function testPreparePaste(done: () => void) {
  const myFilesVolume = volumeManager.volumeInfoList.item(1);
  const myFilesMockFs = myFilesVolume.fileSystem as MockFileSystem;
  myFilesMockFs.populate(['/testfile.txt', '/testdir/']);
  const testFile = MockFileEntry.create(myFilesMockFs, '/testfile.txt');
  const testDir = MockDirectoryEntry.create(myFilesMockFs, '/testdir');

  // FilesApp internal drag and drop should populate sourceURLs at first, and
  // only populate sourceEntries after calling resolveEntries().
  const filesAppDataTransfer = new DataTransfer();
  filesAppDataTransfer.setData('fs/sources', testFile.toURL());
  const filesAppPastePlan =
      fileTransferController.preparePaste(filesAppDataTransfer, testDir);
  assertEquals(filesAppPastePlan.sourceURLs.length, 1);
  assertEquals(filesAppPastePlan.sourceEntries.length, 0);
  await filesAppPastePlan.resolveEntries();
  assertEquals(filesAppPastePlan.sourceEntries.length, 1);
  assertEquals(filesAppPastePlan.sourceEntries[0], testFile);

  // Drag and drop from other apps will use DataTransfer.item with
  // item.kind === 'file', and use webkitGetAsEntry() to populate sourceEntries.
  const otherMockFs = new MockFileSystem('not-filesapp');
  const otherFile = MockFileEntry.create(otherMockFs, '/otherfile.txt');
  const otherDataTransfer = {
    effectAllowed: 'copy',
    getData: () => {
      return '';
    },
    items: [{
      kind: 'file',
      webkitGetAsEntry: () => {
        return otherFile;
      },
    }],
  } as unknown as DataTransfer;
  const otherPastePlan =
      fileTransferController.preparePaste(otherDataTransfer, testDir);
  assertEquals(otherPastePlan.sourceURLs.length, 0);
  assertEquals(otherPastePlan.sourceEntries.length, 1);
  assertEquals(otherPastePlan.sourceEntries[0], otherFile);
  await otherPastePlan.resolveEntries();
  assertEquals(otherPastePlan.sourceURLs.length, 0);
  assertEquals(otherPastePlan.sourceEntries.length, 1);
  assertEquals(otherPastePlan.sourceEntries[0], otherFile);

  // Drag and drop browser file will use DataTransfer.item with
  // item.kind === 'file', but webkitGetAsEntry() will not resolve the file.
  const fakeWriteFile: typeof writeFile = async (file, dir) => {
    return MockFileEntry.create(
        myFilesMockFs, `${dir.fullPath}/${file.name}`, undefined, file);
  };

  const browserFileDataTransfer = new DataTransfer();
  browserFileDataTransfer.items.add(
      new File(['content'], 'browserfile', {type: 'text/plain'}));
  const browserFilePastePlan = fileTransferController.preparePaste(
      browserFileDataTransfer, testDir, undefined, fakeWriteFile);
  // sourceURLs and sourceEntries should not be populated from File instances.
  assertEquals(browserFilePastePlan.sourceURLs.length, 0);
  assertEquals(browserFilePastePlan.sourceEntries.length, 0);

  // File instances should still be copied to target folder.
  const writtenEntry =
      myFilesMockFs.entries['/testdir/browserfile']! as MockFileEntry;
  assertEquals('content', await writtenEntry.content.text());

  done();
}

/**
 * Tests the drag-and-drop's `isDropTargetAllowed_` utility function, which
 * relies on the local storage that mirrors the data stored in the clipboard,
 * and on the "disabled" state of the target entry in the store.
 *
 * Note: Setting the drop target used to infer the dragged entries from the
 * selection handler, which is not valid when entries are dragged from one Files
 * window to another.
 */
export async function testDropTargetAllowed(done: () => void) {
  // Item 1 of the volume info list should be Downloads volume type.
  assertEquals(
      VolumeType.DOWNLOADS, volumeManager.volumeInfoList.item(1).volumeType);
  const myFilesVolume = volumeManager.volumeInfoList.item(1);
  const myFilesMockFs = myFilesVolume.fileSystem as MockFileSystem;

  // Create entries under myFiles and cache them in the store.
  myFilesMockFs.populate(
      ['/file.txt', '/dir/', '/dir/file2.txt', '/dir/dir2/']);
  const file = myFilesMockFs.entries['/file.txt'] as FileEntry;
  const dir = myFilesMockFs.entries['/dir'] as DirectoryEntry;
  const file2 = myFilesMockFs.entries['/dir/file2.txt'] as FileEntry;
  const dir2 = myFilesMockFs.entries['/dir/dir2'] as FileEntry;
  const store = setupStore();
  cacheEntries(store.getState(), [file, dir, file2]);

  // Update the local storage to simulate dragging the "/dir" entry.
  const storage = window.localStorage;
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_URLS}`,
      entriesToURLs([dir]).join('\n'));
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_ROOT_URL}`, myFilesMockFs.rootURL);
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${MISSING_FILE_CONTENTS}`, 'false');
  storage.setItem(`${DRAG_AND_DROP_GLOBAL_DATA}.${ENCRYPTED}`, 'false');

  // "/dir" is not a valid target for itself.
  assertFalse(fileTransferController.isDropTargetAllowed(dir.toURL()));

  // Check that "/dir" is a valid target for "/file.txt".
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_URLS}`,
      entriesToURLs([file]).join('\n'));
  assertTrue(fileTransferController.isDropTargetAllowed(dir.toURL()));

  // Check that "/dir" is a valid target for "/dir/file2.txt".
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_URLS}`,
      entriesToURLs([file2]).join('\n'));
  assertTrue(fileTransferController.isDropTargetAllowed(dir.toURL()));

  // Check that "/dir/dir2" is not a valid target for "/dir".
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_URLS}`,
      entriesToURLs([dir]).join('\n'));
  assertFalse(fileTransferController.isDropTargetAllowed(dir2.toURL()));

  // Disable the directory "/dir" in the store and check that it can't be used
  // as a drop target anymore for "/file.txt".
  const dirFileData = getFileData(store.getState(), dir.toURL())!;
  dirFileData.disabled = true;
  storage.setItem(
      `${DRAG_AND_DROP_GLOBAL_DATA}.${SOURCE_URLS}`,
      entriesToURLs([file]).join('\n'));
  assertFalse(fileTransferController.isDropTargetAllowed(dir.toURL()));

  done();
}

/**
 * Size of directory.
 */
const DIRECTORY_SIZE = -1;

/**
 * Creates test file system.
 * @param id File system Id.
 * @param entries Map of entry paths and their size.
 *     If the entry size is DIRECTORY_SIZE, the entry is a directory.
 */
function createTestFileSystem(
    id: string, entries: Record<string, number>): MockFileSystem {
  const fileSystem = new MockFileSystem(id, 'filesystem:' + id);
  for (const path in entries) {
    if (entries[path] === DIRECTORY_SIZE) {
      fileSystem.entries[path] = MockDirectoryEntry.create(fileSystem, path);
    } else {
      const metadata:
          Metadata = {size: entries[path]!, modificationTime: new Date()};
      fileSystem.entries[path] =
          MockFileEntry.create(fileSystem, path, metadata);
    }
  }
  return fileSystem;
}

/**
 * Tests the resolvePath() function.
 */
export async function testResolvePath(done: VoidCallback) {
  const fileSystem = createTestFileSystem('testVolume', {
    '/': DIRECTORY_SIZE,
    '/file': 10,
    '/directory': DIRECTORY_SIZE,
  });
  const root = fileSystem.root;
  const resolvedRoot = await resolvePath(root, '/');
  assertEquals(fileSystem.entries['/'], resolvedRoot);

  const resolvedFile = await resolvePath(root, '/file');
  assertEquals(fileSystem.entries['/file'], resolvedFile);

  const resolvedDirectory = await resolvePath(root, '/directory');
  assertEquals(fileSystem.entries['/directory'], resolvedDirectory);

  try {
    await resolvePath(root, '/not_found');
    assertNotReached('The NOT_FOUND error is not reported.');
  } catch (error: unknown) {
    assertEquals('NotFoundError', (error as any).name);
  }

  done();
}

/**
 * Tests the deduplicatePath() function.
 */
export async function testDeduplicatePath(done: VoidCallback) {
  const fileSystem1 = createTestFileSystem('testVolume', {'/': DIRECTORY_SIZE});
  const fileSystem2 = createTestFileSystem('testVolume', {
    '/': DIRECTORY_SIZE,
    '/file.txt': 10,
  });
  const fileSystem3 = createTestFileSystem('testVolume', {
    '/': DIRECTORY_SIZE,
    '/file.txt': 10,
    '/file (1).txt': 10,
    '/file (2).txt': 10,
    '/file (3).txt': 10,
    '/file (4).txt': 10,
    '/file (5).txt': 10,
    '/file (6).txt': 10,
    '/file (7).txt': 10,
    '/file (8).txt': 10,
    '/file (9).txt': 10,
  });

  const nonExistingPath = await deduplicatePath(fileSystem1.root, 'file.txt');
  assertEquals('file.txt', nonExistingPath);

  const existingPath = await deduplicatePath(fileSystem2.root, 'file.txt');
  assertEquals('file (1).txt', existingPath);

  const moreExistingPath = await deduplicatePath(fileSystem3.root, 'file.txt');
  assertEquals('file (10).txt', moreExistingPath);

  done();
}

/**
 * Test writeFile() with file dragged from browser.
 */
export async function testWriteFile(done: VoidCallback) {
  const fileSystem = createTestFileSystem('testVolume', {
    '/testdir': DIRECTORY_SIZE,
  });

  const file = new File(['content'], 'browserfile', {type: 'text/plain'});
  await writeFile(file, fileSystem.entries['/testdir'] as DirectoryEntry);
  const writtenEntry =
      fileSystem.entries['/testdir/browserfile'] as MockFileEntry;
  assertEquals('content', await writtenEntry.content.text());
  done();
}