chromium/ui/file_manager/integration_tests/file_manager/file_list.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 type {ElementObject} from '../prod/file_manager/shared_types.js';
import {ENTRIES, RootPath, sendTestMessage} from '../test_util.js';

import {remoteCall} from './background.js';
import {BASIC_LOCAL_ENTRY_SET} from './test_data.js';

/**
 * Send Tab keys to Files app until #file-list gets a selected item.
 *
 * Raise test failure if #file-list doesn't get anything selected in 20 tabs.
 *
 * NOTE:
 *   1. Sends a maximum of 20 tabs.
 *   2. The focused element after this function might NOT be #file-list, since
 *   updating the selected item which is async can be detected after an extra
 *   Tab has been sent.
 */
async function tabUntilFileSelected(appId: string) {
  // Check: the file-list should have nothing selected.
  const selectedRows =
      await remoteCall.queryElements(appId, ['#file-list li[selected]']);
  chrome.test.assertEq(0, selectedRows.length);

  // Press the tab until file-list gets focus and select some file.
  for (let i = 0; i < 20; i++) {
    // Send Tab key.
    const result = await sendTestMessage({name: 'dispatchTabKey'});
    chrome.test.assertEq(result, 'tabKeyDispatched', 'Tab key dispatch failed');

    // Check if there is a file selected, and return if so.
    const selectedRows =
        await remoteCall.queryElements(appId, ['#file-list li[selected]']);
    if (selectedRows.length > 0) {
      return;
    }
  }

  chrome.test.assertTrue(false, 'File list has no selection');
}

/**
 * Tests that file list column header have ARIA attributes.
 */
export async function fileListAriaAttributes() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.beautiful], []);

  // Fetch column header.
  const columnHeadersQuery = ['#detail-table .table-header [aria-describedby]'];
  const columnHeaders =
      await remoteCall.queryElements(appId, columnHeadersQuery, ['display']);

  chrome.test.assertTrue(columnHeaders.length > 0);
  for (const header of columnHeaders) {
    // aria-describedby is used to tell users that they can click and which
    // type of sort asc/desc will happen.
    chrome.test.assertTrue('aria-describedby' in header.attributes);
    // role button is used so users know that it's clickable.
    chrome.test.assertEq('button', header.attributes['role']);
  }
}

/**
 * Tests using tab to focus the file list will select the first item, if
 * nothing is selected.
 */
export async function fileListFocusFirstItem() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  // Send Tab keys to make file selected.
  await tabUntilFileSelected(appId);

  // Check: The first file only is selected in the file entry rows.
  const fileRows: ElementObject[] =
      await remoteCall.queryElements(appId, ['#file-list li']);
  chrome.test.assertEq(5, fileRows.length);
  const selectedRows = fileRows.filter(item => 'selected' in item.attributes);
  chrome.test.assertEq(1, selectedRows.length);
  chrome.test.assertEq(0, fileRows.indexOf(selectedRows[0]!));
}

/**
 * Tests that after a multiple selection, canceling the selection and using
 * Tab to focus the files list it selects the item that was last focused.
 */
export async function fileListSelectLastFocusedItem() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  // Check: the file-list should have nothing selected.
  let selectedRows =
      await remoteCall.queryElements(appId, ['#file-list li[selected]']);
  chrome.test.assertEq(0, selectedRows.length);

  // Move to item 2.
  const downKey = ['#file-list', 'ArrowDown', false, false, false];
  for (let i = 0; i < 2; i++) {
    chrome.test.assertTrue(
        !!await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, downKey),
        'ArrowDown failed');
  }

  // Select item 2 with Ctrl+Space.
  const ctrlSpace = ['#file-list', ' ', true, false, false];
  chrome.test.assertTrue(
      !!await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, ctrlSpace),
      'Ctrl+Space failed');

  // Move to item 3 and Cltr+Space to add it to multi-selection.
  const ctrlDown = ['#file-list', 'ArrowDown', true, false, false];
  chrome.test.assertTrue(
      !!await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, ctrlDown),
      'Ctrl+ArrowDown failed');
  chrome.test.assertTrue(
      !!await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, ctrlSpace),
      'Ctrl+Space failed');

  // Check that items 2 and 3 are selected.
  selectedRows =
      await remoteCall.queryElements(appId, ['#file-list li[selected]']);
  chrome.test.assertEq(2, selectedRows.length);

  // Cancel the selection.
  await remoteCall.waitAndClickElement(appId, '#cancel-selection-button');

  // Wait until selection is removed.
  await remoteCall.waitForElementLost(appId, '#file-list li[selected]');

  // Send Tab keys until a file item is selected.
  await tabUntilFileSelected(appId);

  // Check: The 3rd item only is selected.
  const fileRows: ElementObject[] =
      await remoteCall.queryElements(appId, ['#file-list li']);
  chrome.test.assertEq(5, fileRows.length);
  selectedRows = fileRows.filter(item => 'selected' in item.attributes);
  chrome.test.assertEq(1, selectedRows.length);
  chrome.test.assertEq(2, fileRows.indexOf(selectedRows[0]!));
}

/**
 * Tests that after a multiple selection, canceling the selection and using
 * Tab to focus the files list it selects the item that was last focused.
 */
export async function fileListSortWithKeyboard() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  // Send shift-Tab key to tab into sort button.
  const result = await sendTestMessage({name: 'dispatchTabKey', shift: true});
  chrome.test.assertEq(result, 'tabKeyDispatched', 'Tab key dispatch failed');
  // Check: sort button has focus.
  let focusedElement = await remoteCall.callRemoteTestUtil<ElementObject>(
      'getActiveElement', appId, []);
  chrome.test.assertTrue(!!focusedElement);
  // Check: button is showing down arrow.
  chrome.test.assertTrue(
      focusedElement['attributes']['iron-icon'] === 'files16:arrow_down_small');
  // Check: aria-label tells us to click to sort ascending.
  chrome.test.assertTrue(
      focusedElement['attributes']['aria-label'] ===
      'Click to sort the column in ascending order.');
  // Press 'enter' on the sort button.
  const key = ['cr-icon-button[tabindex="0"]', 'Enter', false, false, false];
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));
  // Get the state of the (focused) sort button.
  focusedElement =
      await remoteCall.callRemoteTestUtil('getActiveElement', appId, []);
  // Check: button is showing up arrow.
  chrome.test.assertTrue(
      focusedElement['attributes']['iron-icon'] === 'files16:arrow_up_small');
  // Check: aria-label tells us to click to sort descending.
  chrome.test.assertTrue(
      focusedElement['attributes']['aria-label'] ===
      'Click to sort the column in descending order.');
  // Press 'enter' key on the sort button again.
  chrome.test.assertTrue(
      await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, key));
  // Get the state of the (focused) sort button.
  focusedElement =
      await remoteCall.callRemoteTestUtil('getActiveElement', appId, []);
  // Check: button is showing up arrow.
  chrome.test.assertTrue(
      focusedElement['attributes']['iron-icon'] === 'files16:arrow_down_small');
  // Check: aria-label tells us to click to sort descending.
  chrome.test.assertTrue(
      focusedElement['attributes']['aria-label'] ===
      'Click to sort the column in ascending order.');
}

/**
 * Verifies the total number of a11y messages and asserts the latest message
 * is the expected one.
 *
 * @return Latest a11y message.
 */
async function countAndCheckLatestA11yMessage(
    appId: string, expectedCount: number,
    expectedMessage?: null|string): Promise<string> {
  const a11yMessages = await remoteCall.callRemoteTestUtil<string[]>(
      'getA11yAnnounces', appId, []);
  if (expectedMessage === null || expectedMessage === undefined) {
    return '';
  }
  const latestMessage = a11yMessages[a11yMessages.length - 1];

  chrome.test.assertEq(
      expectedCount, a11yMessages.length,
      `Wrong number of a11y messages: latest message: ${
          latestMessage} \nAll messages:\n ${a11yMessages.join('\n-')}`);
  chrome.test.assertEq(expectedMessage, latestMessage);
  return latestMessage!;
}

/**
 * Tests that selecting/de-selecting files with keyboard produces a11y
 * messages.
 *
 * NOTE: Test shared with grid_view.js.
 * @param isGridView if the test is testing the grid view.
 */
export async function fileListKeyboardSelectionA11yImpl(isGridView?: boolean) {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  let a11yMsgCount = 0;
  const viewSelector = isGridView ? 'grid#file-list' : '#file-list';
  if (isGridView) {
    // Click view-button again to switch to detail view.
    await remoteCall.waitAndClickElement(appId, '#view-button');

    // Clicking #view-button adds 1 a11y message.
    ++a11yMsgCount;
  }

  // Keys used for keyboard navigation in the file list.
  const homeKey = [viewSelector, 'Home', false, false, false] as const;
  const ctrlDownKey = [viewSelector, 'ArrowDown', true, false, false] as const;
  const ctrlSpaceKey = [viewSelector, ' ', true, false, false] as const;
  const shiftEndKey = [viewSelector, 'End', false, true, false] as const;
  const ctrlAKey = [viewSelector + ' li', 'a', true, false, false] as const;
  const escKey = [viewSelector, 'Escape', false, false, false] as const;

  // Select first item with Home key.
  await remoteCall.fakeKeyDown(appId, ...homeKey);

  // Navigating with Home key doesn't use aria-live message, it only uses the
  // native listbox selection state messages.
  await countAndCheckLatestA11yMessage(appId, a11yMsgCount);

  // Ctrl+Down & Ctrl+Space to select second item: Beautiful Song.ogg
  await remoteCall.fakeKeyDown(appId, ...ctrlDownKey);
  await remoteCall.fakeKeyDown(appId, ...ctrlSpaceKey);

  // Check: Announced "Beautiful Song.add" added to selection.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Added Beautiful Song.ogg to selection.');

  // Shift+End to select from 2nd item to the last item.
  await remoteCall.fakeKeyDown(appId, ...shiftEndKey);

  // Check: Announced range selection from "Beautiful Song.add" to hello.txt.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount,
      'Selected a range of 4 entries from Beautiful Song.ogg to hello.txt.');

  // Ctrl+Space to de-select currently focused item (last item).
  await remoteCall.fakeKeyDown(appId, ...ctrlSpaceKey);

  // Check: Announced de-selecting hello.txt
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Removed hello.txt from selection.');

  // Ctrl+A to select all items.
  await remoteCall.fakeKeyDown(appId, ...ctrlAKey);

  // Check: Announced selecting all entries.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Selected all entries.');

  // Esc key to deselect all.
  await remoteCall.fakeKeyDown(appId, ...escKey);

  // Check: Announced deselecting all entries.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Removed all entries from selection.');
}

export async function fileListKeyboardSelectionA11y() {
  return fileListKeyboardSelectionA11yImpl(/*isGridView*/ false);
}

/**
 * Tests that selecting/de-selecting files with mouse produces a11y messages.
 *
 * NOTE: Test shared with grid_view.js.
 * @param isGridView if the test is testing the grid view.
 */
export async function fileListMouseSelectionA11yImpl(isGridView?: boolean) {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  let a11yMsgCount = 0;
  if (isGridView) {
    // Click view-button again to switch to detail view.
    await remoteCall.waitAndClickElement(appId, '#view-button');

    // Clicking #view-button adds 1 a11y message.
    ++a11yMsgCount;
  }

  // Click first item.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="photos"]');

  // Simple click to select a file doesn't use aria-live message, it only
  // uses the native listbox selection state messages.
  await countAndCheckLatestA11yMessage(appId, a11yMsgCount);

  // Ctrl+Click second item.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="Beautiful Song.ogg"]', {ctrl: true});

  // Check: Announced "Beautiful Song.add" added to selection.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Added Beautiful Song.ogg to selection.');

  // Shift+Click last item.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="hello.txt"]', {shift: true});

  // Check: Announced range selection from "Beautiful Song.add" to hello.txt.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount,
      'Selected a range of 4 entries from Beautiful Song.ogg to hello.txt.');

  // Ctrl+Click to de-select the last item.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="hello.txt"]', {ctrl: true});

  // Check: Announced de-selecting hello.txt
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Removed hello.txt from selection.');

  // Click on "Cancel selection" button.
  await remoteCall.waitAndClickElement(appId, '#cancel-selection-button');

  // Check: Announced deselecting all entries.
  await countAndCheckLatestA11yMessage(
      appId, ++a11yMsgCount, 'Removed all entries from selection.');
}

export async function fileListMouseSelectionA11y() {
  return fileListMouseSelectionA11yImpl(/*isGridView*/ false);
}

/**
 * Tests the deletion of one or multiple items. After deletion, one of the
 * remaining items should have the lead, but shouldn't be in check-select
 * mode.
 */
export async function fileListDeleteMultipleFiles() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  // Select first 2 items.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="world.ogv"]');
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="hello.txt"]', {shift: true});

  // Press move to trash button.
  await remoteCall.waitAndClickElement(appId, '#move-to-trash-button');

  // Wait for completion of file deletion.
  await remoteCall.waitForElementLost(
      appId, '#file-list [file-name="world.ogv"]');
  await remoteCall.waitForElementLost(
      appId, '#file-list [file-name="hello.txt"]');

  // Check: no selection state.
  await remoteCall.waitForElementLost(appId, 'body.check-select');

  // Check: lead state of last item.
  let item = await remoteCall.waitForElement(
      appId, '#file-list [file-name="My Desktop Background.png"]');
  chrome.test.assertTrue('lead' in item.attributes);

  // Check: selected state of last item.
  chrome.test.assertTrue('selected' in item.attributes);

  // Select and move the first item to trash.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="photos"]');
  await remoteCall.waitAndClickElement(appId, '#move-to-trash-button');

  // Wait for file deletion.
  await remoteCall.waitForElementLost(appId, '#file-list [file-name="photos"]');

  // Check: no selection state.
  await remoteCall.waitForElementLost(appId, 'body.check-select');

  // Check: lead state of first item.
  item = await remoteCall.waitForElement(
      appId, '#file-list [file-name="Beautiful Song.ogg"]');
  chrome.test.assertTrue('lead' in item.attributes);

  // Check: selected state of first item.
  chrome.test.assertTrue('selected' in item.attributes);
}

/**
 * Tests that in selection mode, the rename operation is applied to the
 * selected item, as seen by the selection model, rather than the lead item.
 * The lead and the selected item(s) are different when we deselect a file
 * list item in selection mode. crbug.com/1094260
 */
export async function fileListRenameSelectedItem() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);

  // Select 2 items.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="hello.txt"]');
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="world.ogv"]', {ctrl: true});

  // Deselect first item.
  await remoteCall.waitAndClickElement(
      appId, '#file-list [file-name="hello.txt"]', {ctrl: true});

  // Check: the first item should have the lead state.
  const item = await remoteCall.waitForElement(
      appId, '#file-list li:not([selected])[file-name="hello.txt"]');
  chrome.test.assertTrue('lead' in item.attributes);

  // Check: selection should be on the second item.
  await remoteCall.waitForElement(
      appId, '#file-list li[selected][file-name="world.ogv"]');

  // Press Ctrl+Enter key to rename the selected 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.
  const textInput = '#file-list .table-row[renaming] input.rename';
  await remoteCall.waitForElement(appId, textInput);

  // Type new file name.
  await remoteCall.inputText(appId, textInput, 'New File Name.txt');

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

  // Check: the selected file should have been renamed.
  await remoteCall.waitForElementLost(
      appId, '#file-list [file-name="world.ogv"]');
  await remoteCall.waitForElement(
      appId, '#file-list li[selected][file-name="New File Name.txt"]');
}

/**
 * Tests that user can rename a file/folder after using "select all" without
 * having selected any file previously.
 */
export async function fileListRenameFromSelectAll() {
  const appId = await remoteCall.setupAndWaitUntilReady(
      RootPath.DOWNLOADS, [ENTRIES.beautiful], []);

  // Select all the files.
  const ctrlA = ['#file-list', 'a', true, false, false];
  await remoteCall.callRemoteTestUtil('fakeKeyDown', appId, ctrlA);

  // Check: the file-list should be selected.
  await remoteCall.waitForElement(appId, '#file-list li[selected]');

  // Wait and click on "selection menu button".
  await remoteCall.waitAndClickElement(
      appId, '#selection-menu-button:not([hidden])');

  // Wait and click on rename menu item.
  await remoteCall.waitAndClickElement(
      appId, '[command="#rename"]:not([hidden]):not([disabled])');

  // Check: the renaming text input should be shown in the file list.
  const textInput = '#file-list .table-row[renaming] input.rename';
  await remoteCall.waitForElement(appId, textInput);
}