chromium/ui/file_manager/integration_tests/file_manager/keyboard_operations.ts

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

import {ENTRIES, getCaller, pending, repeatUntil, RootPath, sendTestMessage, TestEntryInfo} from '../test_util.js';

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

/**
 * Waits until a dialog with an OK button is shown, and accepts it by clicking
 * on the dialog's OK button.
 *
 * @param appId The Files app windowId.
 * @return Promise to be fulfilled after clicking the OK button.
 */
async function waitAndAcceptDialog(appId: string): Promise<void> {
  const okButton = '.cr-dialog-ok';

  // Wait until the dialog is shown.
  await remoteCall.waitForElement(appId, '.cr-dialog-container.shown');

  // Click the dialog OK button.
  await remoteCall.waitAndClickElement(appId, okButton);

  // Wait until the dialog closes.
  await remoteCall.waitForElementLost(appId, '.cr-dialog-container');
}

/**
 * Tests copying a file to the same file list.
 *
 * @param path The path to be tested, Downloads or Drive.
 */
async function keyboardCopy(path: string) {
  const appId = await remoteCall.setupAndWaitUntilReady(
      path, [ENTRIES.world], [ENTRIES.world]);

  // Copy the file into the same file list.
  chrome.test.assertTrue(
      (await remoteCall.callRemoteTestUtil<boolean>(
          'copyFile', appId, ['world.ogv'])),
      'copyFile failed');
  // Check: the copied file should appear in the file list.
  const expectedEntryRows = [ENTRIES.world.getExpectedRow()].concat(
      [['world (1).ogv', '56 KB', 'OGG video', '']]);
  await remoteCall.waitForFiles(
      appId, expectedEntryRows, {ignoreLastModifiedTime: true});
  const files =
      await remoteCall.callRemoteTestUtil<string[]>('getFileList', appId, []);
  if (path === RootPath.DRIVE) {
    // DriveFs doesn't preserve mtimes so they shouldn't match.
    chrome.test.assertTrue(files[0]![3] !== files[1]![3], files[0]![3]);
  } else {
    // The mtimes should match for Local files.
    chrome.test.assertTrue(files[0]![3] === files[1]![3], files[0]![3]);
  }
}

/**
 * Tests deleting a file from the file list.
 *
 * @param path The path to be tested, Downloads or Drive.
 * @param confirmDeletion If the file system doesn't support trash, need to
 *     confirm the deletion.
 */
async function keyboardDelete(path: string, confirmDeletion: boolean = false) {
  const appId = await remoteCall.setupAndWaitUntilReady(
      path, [ENTRIES.hello], [ENTRIES.hello]);

  // Delete the file from the file list.
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('deleteFile', appId, ['hello.txt']),
      'deleteFile failed');

  if (confirmDeletion) {
    await waitAndAcceptDialog(appId);
  }

  // Check: the file list should be empty.
  await remoteCall.waitForFiles(appId, []);
}

/**
 * Tests deleting a folder from the file list. The folder is also shown in the
 * Files app directory tree, and should not be shown there when deleted.
 *
 * @param path The path to be tested, Downloads or Drive.
 * @param parentLabel The directory tree item label.
 * @param confirmDeletion If the file system doesn't support trash, need to
 *     confirm the deletion.
 */
async function keyboardDeleteFolder(
    path: string, parentLabel: string, confirmDeletion: boolean = false) {
  const appId = await remoteCall.setupAndWaitUntilReady(
      path, [ENTRIES.photos], [ENTRIES.photos]);

  // Expand the directory tree |treeItem|.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.expandTreeItemByLabel(parentLabel);

  // Check: the folder should be shown in the directory tree.
  await directoryTree.waitForChildItemByLabel(parentLabel, 'photos');

  // Delete the folder entry from the file list.
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('deleteFile', appId, ['photos']),
      'deleteFile failed');

  if (confirmDeletion) {
    await waitAndAcceptDialog(appId);
  }

  // Check: the file list should be empty.
  await remoteCall.waitForFiles(appId, []);

  // Check: the folder should not be shown in the directory tree.
  await directoryTree.waitForChildItemLostByLabel(parentLabel, 'photos');
}

/**
 * Renames a file.
 *
 * @param appId The Files app windowId.
 * @param oldName Old name of a file.
 * @param newName New name of a file.
 * @return Promise to be fulfilled on success.
 */
async function renameFile(
    appId: string, oldName: string, newName: string): Promise<void> {
  const textInput = '#file-list .table-row[renaming] input.rename';

  // Select the file.
  await remoteCall.waitUntilSelected(appId, oldName);

  // Press Ctrl+Enter key to rename the file.
  const key = ['#file-list', 'Enter', true, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Check: the renaming text input should be shown in the file list.
  await remoteCall.waitForElement(appId, textInput);

  // Type new file name.
  await remoteCall.inputText(appId, textInput, newName);

  // Send Enter key to the text input.
  const key2 = [textInput, 'Enter', false, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key2));
}

/**
 * Tests renaming a folder. An extra enter key is sent to the file list during
 * renaming to check the folder cannot be entered while it is being renamed.
 *
 * @param path Initial path (Downloads or Drive).
 * @param parentLabel The directory tree item label.
 * @return Promise to be fulfilled on success.
 */
async function testRenameFolder(
    path: string, parentLabel: string): Promise<void> {
  const textInput = '#file-list .table-row[renaming] input.rename';
  const appId = await remoteCall.setupAndWaitUntilReady(
      path, [ENTRIES.photos], [ENTRIES.photos]);

  // Expand the directory tree |treeItem|.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.expandTreeItemByLabel(parentLabel);

  // Check: the photos folder should be shown in the directory tree.
  await directoryTree.waitForChildItemByLabel(parentLabel, 'photos');
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('focus', appId, ['#file-list']));

  // Press ArrowDown to select the photos folder.
  const select = ['#file-list', 'ArrowDown', false, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, select));

  // Await file list item selection.
  const selectedItem = '#file-list .table-row[selected]';
  await remoteCall.waitForElement(appId, selectedItem);

  // Press Ctrl+Enter to rename the photos folder.
  let key = ['#file-list', 'Enter', true, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Check: the renaming text input should be shown in the file list.
  await remoteCall.waitForElement(appId, textInput);

  // Type the new folder name.
  await remoteCall.inputText(appId, textInput, 'bbq photos');

  // Send Enter to the list to attempt to enter the directory.
  key = ['#list-container', 'Enter', false, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Send Enter to the text input to complete renaming.
  key = [textInput, 'Enter', false, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Wait until renaming is complete.
  const renamingItem = '#file-list .table-row[renaming]';
  await remoteCall.waitForElementLost(appId, renamingItem);

  // Check: the renamed folder should be shown in the file list.
  const expectedRows = [['bbq photos', '--', 'Folder', '']];
  await remoteCall.waitForFiles(
      appId, expectedRows, {ignoreLastModifiedTime: true});

  // Check: the renamed folder should be shown in the directory tree.
  await directoryTree.waitForChildItemByLabel(parentLabel, 'bbq photos');
}

/**
 * Tests renaming a file.
 *
 * @param path Initial path (Downloads or Drive).
 * @return Promise to be fulfilled on success.
 */
async function testRenameFile(path: string): Promise<void> {
  const newFile = [['New File Name.txt', '51 bytes', 'Plain text', '']];

  const appId = await remoteCall.setupAndWaitUntilReady(
      path, [ENTRIES.hello], [ENTRIES.hello]);

  // Rename the file.
  await renameFile(appId, 'hello.txt', 'New File Name.txt');

  // Wait until renaming completes.
  await remoteCall.waitForElementLost(appId, '#file-list [renaming]');

  // Check: the new file name should be shown in the file list.
  await remoteCall.waitForFiles(appId, newFile, {ignoreLastModifiedTime: true});

  // Try renaming the new file to an invalid file name.
  await renameFile(appId, 'New File Name.txt', '.hidden file');

  // Check: the error dialog should be shown.
  await waitAndAcceptDialog(appId);

  // Check: the new file name should not be changed.
  await remoteCall.waitForFiles(appId, newFile, {ignoreLastModifiedTime: true});
}

export function keyboardCopyDownloads() {
  return keyboardCopy(RootPath.DOWNLOADS);
}

export function keyboardCopyDrive() {
  return keyboardCopy(RootPath.DRIVE);
}

export function keyboardDeleteDownloads() {
  return keyboardDelete(RootPath.DOWNLOADS);
}

export function keyboardDeleteDrive() {
  return keyboardDelete(RootPath.DRIVE, /*confirmDeletion=*/ true);
}

export function keyboardDeleteFolderDownloads() {
  return keyboardDeleteFolder(RootPath.DOWNLOADS, 'Downloads');
}

export function keyboardDeleteFolderDrive() {
  return keyboardDeleteFolder(
      RootPath.DRIVE, 'My Drive', /*confirmDeletion=*/ true);
}

export function renameFileDownloads() {
  return testRenameFile(RootPath.DOWNLOADS);
}

export function renameFileDrive() {
  return testRenameFile(RootPath.DRIVE);
}

export function renameNewFolderDownloads() {
  return testRenameFolder(RootPath.DOWNLOADS, 'Downloads');
}

export function renameNewFolderDrive() {
  return testRenameFolder(RootPath.DRIVE, 'My Drive');
}

/**
 * Tests renaming partitions with the keyboard on the file list.
 */
export async function renameRemovableWithKeyboardOnFileList() {
  // Open Files app on local downloads.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.world]);

  // Mount removable device with partitions.
  await sendTestMessage({name: 'mountUsbWithMultiplePartitionTypes'});

  // Wait and select the removable group by clicking the label.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.selectGroupRootItemByType('removable');

  // Focus on the file list.
  await remoteCall.callRemoteTestUtil('focus', appId, ['#file-list']);

  // Wait for partitions to show up.
  const expectedRows = [
    ['partition-1', '--', 'ntfs', ''],
    ['partition-2', '--', 'ext4', ''],
    ['partition-3', '--', 'vfat', ''],
  ];
  await remoteCall.waitForFiles(
      appId, expectedRows, {ignoreLastModifiedTime: true});

  // Attempt to rename partition with a label longer than permitted for fat32.
  const partitionToRename = 'partition-3';  // fat32
  await renameFile(appId, partitionToRename, 'very-long-partition-name');

  // Verify that an error was triggered.
  const errorTextElement =
      await remoteCall.waitForElement(appId, '.cr-dialog-text');
  chrome.test.assertEq(
      `Use a name that's 11 characters or less`, errorTextElement.text);

  // Dismiss the error dialog.
  await remoteCall.waitAndClickElement(appId, '.cr-dialog-ok');

  // Enter ctrl+A to select the old text so we can replace it.
  const textInput = '#file-list > li input';
  await remoteCall.callRemoteTestUtil(
      'fakeKeyDown', appId, [textInput, 'A', true, false, false]);

  // Enter a valid name this time.
  const smallerPartitionName = 'smaller';
  const enterKey = [textInput, 'Enter', false, false, false];
  await remoteCall.inputText(appId, textInput, smallerPartitionName);
  await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, enterKey);

  // Wait for the renaming input element to disappear.
  await remoteCall.waitForElementLost(appId, textInput);

  // verify the partition was successfully renamed.
  expectedRows[2]![0] = smallerPartitionName;
  await remoteCall.waitForFiles(
      appId, expectedRows, {ignoreLastModifiedTime: true});
}

/**
 * Tests that the root html element .focus-outline-visible class appears for
 * keyboard interaction and is removed on mouse interaction.
 */
export async function keyboardFocusOutlineVisible() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.hello], []);

  // Check: the html element should have focus-outline-visible class.
  const htmlFocusOutlineVisible = ['html.focus-outline-visible'];
  await remoteCall.waitForElementsCount(appId, htmlFocusOutlineVisible, 1);

  // Send mousedown to the toolbar delete button.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId, ['#move-to-trash-button', 'mousedown']));

  // Check: the html element should not have focus-outline-visible class.
  await remoteCall.waitForElementLost(appId, htmlFocusOutlineVisible);
}

/**
 * Tests that the root html element .pointer-active class is added and removed
 * for mouse interaction.
 */
export async function keyboardFocusOutlineVisibleMouse() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.hello], []);

  // Send mousedown to the toolbar delete button.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId, ['#move-to-trash-button', 'mousedown']));

  // Check: the html element should have pointer-active class.
  const htmlPointerActive = ['html.pointer-active'];
  await remoteCall.waitForElementsCount(appId, htmlPointerActive, 1);

  // Check: the html element should not have focus-outline-visible class.
  await remoteCall.waitForElementLost(appId, ['html.focus-outline-visible']);

  // Send mouseup to the toolbar delete button.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId, ['#move-to-trash-button', 'mouseup']));

  // Check: the html element should not have pointer-active class.
  await remoteCall.waitForElementLost(appId, htmlPointerActive);
}

/**
 * Tests that the root html element .pointer-active class will be removed with
 * pointerup event triggered by touch.
 */
export async function pointerActiveRemovedByTouch() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.hello], []);

  // Send pointerdown to the list container.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId, ['#list-container', 'pointerdown']));

  // Check: the html element should have pointer-active class.
  const htmlPointerActive = ['html.pointer-active'];
  await remoteCall.waitForElementsCount(appId, htmlPointerActive, 1);

  // Send pointerup with touch to the list container.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId,
      ['#list-container', 'pointerup', {pointerType: 'touch'}]));

  // Check: the html element should not have pointer-active class.
  await remoteCall.waitForElementLost(appId, htmlPointerActive);
}

/**
 * Tests that the root html element .pointer-active class should not be added if
 * the PointerDown event is triggered by touch.
 */
export async function noPointerActiveOnTouch() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.hello], []);

  // Send pointerdown with touch to the list container.
  chrome.test.assertTrue(await remoteCall.callRemoteTestUtil(
      'fakeEvent', appId,
      ['#list-container', 'pointerdown', {pointerType: 'touch'}]));

  // Check: the html element should not have pointer-active class.
  const htmlPointerActive = ['html.pointer-active'];
  await remoteCall.waitForElementLost(appId, htmlPointerActive);
}

/**
 * Test that selecting "Google Drive" in the directory tree with the keyboard
 * expands it and selects "My Drive".
 */
export async function keyboardSelectDriveDirectoryTree() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.world], [ENTRIES.hello]);

  // Focus the directory tree.
  const directoryTree = await DirectoryTreePageObject.create(appId);
  await directoryTree.focusTree();

  // Wait for Google Drive root to be available.
  await directoryTree.waitForItemByLabel('Google Drive');

  // The directory tree is the first element focused, so pressing down whilst
  // focused should move through all the volumes until it reaches the drive
  // volume.
  const caller = getCaller();
  await repeatUntil(async () => {
    await directoryTree.focusNextItem();
    const focusedItem = await directoryTree.getFocusedItem();
    if (focusedItem &&
        directoryTree.getItemLabel(focusedItem) === 'Google Drive') {
      return true;
    }
    return pending(caller, 'Moving down until drive volume selected');
  });

  // Ensure it's focused.
  await directoryTree.waitForFocusedItemByLabel('Google Drive');

  // Activate it.
  await directoryTree.selectFocusedItem();

  // It should have expanded.
  await directoryTree.waitForItemToExpandByLabel('Google Drive');

  // My Drive should be selected.
  await directoryTree.waitForSelectedItemByLabel('My Drive');
}

/**
 * Tests that while the delete dialog is displayed, it is not possible to press
 * CONTROL-C to copy a file.
 */
export async function keyboardDisableCopyWhenDialogDisplayed() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DRIVE, [], [ENTRIES.hello]);

  // Select a file for deletion.
  await remoteCall.waitUntilSelected(appId, ENTRIES.hello.nameText);
  await remoteCall.waitForElement(appId, '.table-row[selected]');

  // Click delete button in the toolbar.
  await remoteCall.callRemoteTestUtil(
      'fakeMouseClick', appId, ['#delete-button']);

  // Check: the delete confirm dialog should appear.
  await remoteCall.waitForElement(appId, '.cr-dialog-container.shown');

  // Check: the dialog 'Cancel' button should be focused by default.
  const defaultButton =
      await remoteCall.waitForElement(appId, '.cr-dialog-cancel:focus');
  chrome.test.assertEq('Cancel', defaultButton.text);

  // Try to copy file. We need to use execCommand as the command handler that
  // interprets key strokes will drop events if there is a dialog on screen.
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('execCommand', appId, ['copy']));

  // Click the delete confirm dialog 'Cancel' button to cancel the deletion.
  await remoteCall.waitAndClickElement(appId, '.cr-dialog-cancel');

  // Check: the delete confirm dialog should close.
  await remoteCall.waitForElementLost(appId, '.cr-dialog-container.shown');

  // Send a paste command to the file-list.
  const key = ['#file-list', 'v', true, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Check: no files should be pasted.
  const files = TestEntryInfo.getExpectedRows([ENTRIES.hello]);
  await remoteCall.waitForFiles(appId, files);
}

/**
 * Tests Ctrl+N opens a new windows crbug.com/933302.
 */
export async function keyboardOpenNewWindow() {
  // Open Files app.
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.hello], []);

  // Grab the current open windows.
  const initialWindows = await remoteCall.getWindows();
  const initialWindowsCount = Object.keys(initialWindows).length;

  // Send Ctrl+N to open a new window.
  const key = ['#file-list', 'n', true, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));

  // Wait for the new window to appear.
  return repeatUntil(async () => {
    const caller = getCaller();
    const currentWindows = await remoteCall.getWindows();
    const currentWindowsIds = Object.keys(currentWindows);
    if (initialWindowsCount < currentWindowsIds.length) {
      return true;
    }
    return pending(
        caller,
        'Waiting for new window to open, current windows: ' +
            currentWindowsIds);
  });
}