chromium/ui/file_manager/file_manager/foreground/js/task_controller_unittest.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 {assert} from 'chrome://resources/ash/common/assert.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {assertDeepEquals, assertEquals, assertNotReached, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {createCrostiniForTest} from '../../background/js/mock_crostini.js';
import type {ProgressCenter} from '../../background/js/progress_center.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {crInjectTypeAndInit} from '../../common/js/cr_ui.js';
import {queryDecoratedElement} from '../../common/js/dom_utils.js';
import {isSameEntries} from '../../common/js/entry_utils.js';
import {installMockChrome} from '../../common/js/mock_chrome.js';
import type {MockFileSystem} from '../../common/js/mock_entry.js';
import {MockFileEntry} from '../../common/js/mock_entry.js';
import {descriptorEqual} from '../../common/js/util.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import {changeDirectory} from '../../state/ducks/current_directory.js';
import {setUpFileManagerOnWindow} from '../../state/for_tests.js';
import {PropStatus, type State} from '../../state/state.js';
import {getEmptyState, getStore, waitForState} from '../../state/store.js';

import type {DirectoryModel} from './directory_model.js';
import type {FileSelectionHandler} from './file_selection.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import {MockMetadataModel} from './metadata/mock_metadata.js';
import type {MetadataUpdateController} from './metadata_update_controller.js';
import {TaskController} from './task_controller.js';
import {ComboButton} from './ui/combobutton.js';
import {Command} from './ui/command.js';
import type {FileManagerUI} from './ui/file_manager_ui.js';
import {FilesMenuItem} from './ui/files_menu.js';

/** Mock chrome APIs.  */
let mockChrome: any;

/** VolumeInfo for Downloads volume */
let downloads: VolumeInfo;

// Set up test components.
export function setUp() {
  // Mock chrome APIs.
  mockChrome = {
    commandLinePrivate: {
      hasSwitch: function(_name: string, callback: (v: boolean) => void) {
        callback(false);
      },
    },
    runtime: {
      id: 'test-extension-id',
      lastError: null,
    },
  };

  setupFileManagerPrivate();
  installMockChrome(mockChrome);

  // Install <command> elements on the page.
  document.body.innerHTML = getTrustedHTML`
<command id="default-task">
<command id="open-with">
<cr-menu id="tasks-menu">
  <cr-menu-item id="default-task-menu-item" command="#default-task">
  </cr-menu-item>
</cr-menu>
<cr-button id="tasks" menu="#tasks-menu"> Open </cr-button>
`;

  // Initialize Command with the <command>s.
  for (const command of document.querySelectorAll<Command>('command')) {
    crInjectTypeAndInit(command, Command);
  }

  setUpFileManagerOnWindow();
  const volumeManager = window.fileManager.volumeManager;

  downloads = volumeManager.getCurrentProfileVolumeInfo(VolumeType.DOWNLOADS)!;

  const store = getStore();
  store.init(getEmptyState());

  store.dispatch(changeDirectory({
    to: downloads.displayRoot,
    toKey: downloads.displayRoot.toURL(),
    status: PropStatus.SUCCESS,
  }));
}

function createTaskController(fileSelectionHandler: FileSelectionHandler):
    TaskController {
  const taskController = new TaskController(
      {
        getLocationInfo: function(_entry: Entry) {
          return RootType.DRIVE;
        },
        getDriveConnectionState: function() {
          return 'ONLINE';
        },
        getVolumeInfo: function() {
          return {
            volumeType: VolumeType.DRIVE,
          };
        },
      } as unknown as VolumeManager,
      {
        taskMenuButton: queryDecoratedElement('#tasks', ComboButton),
        defaultTaskMenuItem:
            queryDecoratedElement('#default-task-menu-item', FilesMenuItem),
        speakA11yMessage: (_text: string) => {},
        listContainer: document.createElement('div'),
        tasksSeparator: document.createElement('hr'),
      } as unknown as FileManagerUI,
      new MockMetadataModel({}) as unknown as MetadataModel, {
        getCurrentRootType: () => null,
      } as unknown as DirectoryModel,
      fileSelectionHandler, {} as unknown as MetadataUpdateController,
      createCrostiniForTest(), {} as unknown as ProgressCenter);

  return taskController;
}

/**
 * Setup test case fileManagerPrivate.
 */
function setupFileManagerPrivate() {
  mockChrome.fileManagerPrivate = {
    getFileTaskCalledCount_: 0,
    getFileTaskCalledEntries_: [],
    getFileTasks: function(
        entries: Entry[], _sourceUrls: string[],
        callback: (tasks: any) => void) {
      mockChrome.fileManagerPrivate.getFileTaskCalledCount_++;
      mockChrome.fileManagerPrivate.getFileTaskCalledEntries_.push(entries);
      const fileTasks = ([
        {
          descriptor: {
            appId: 'handler-extension-id',
            taskType: 'file',
            actionId: 'open',
          },
          isDefault: false,
        },
        {
          descriptor: {
            appId: 'handler-extension-id',
            taskType: 'file',
            actionId: 'play',
          },
          isDefault: true,
        },
      ]);
      setTimeout(callback.bind(null, {tasks: fileTasks}), 0);
    },
  };
}

/**
 * Tests that executeEntryTask() runs the expected task.
 */
export async function testExecuteEntryTask() {
  const selectionHandler = window.fileManager.selectionHandler;

  const fileSystem = downloads.fileSystem as MockFileSystem;
  fileSystem.entries['/test.png'] =
      MockFileEntry.create(fileSystem, '/test.png');
  const taskController = createTaskController(selectionHandler);

  const testEntry = fileSystem.entries['/test.png'] as Entry;
  taskController.executeEntryTask(testEntry);

  const descriptor =
      await new Promise<chrome.fileManagerPrivate.FileTaskDescriptor>(
          (resolve) => {
            chrome.fileManagerPrivate.executeTask = resolve;
          });

  assert(descriptorEqual(
      {appId: 'handler-extension-id', taskType: 'file', actionId: 'play'},
      descriptor));
}

/**
 * Tests that getFileTasks() does not call .fileManagerPrivate.getFileTasks()
 * multiple times when the selected entries are not changed.
 */
export async function testGetFileTasksShouldNotBeCalledMultipleTimes() {
  const selectionHandler = window.fileManager.selectionHandler;
  const store = getStore();
  const taskController =
      createTaskController(selectionHandler as unknown as FileSelectionHandler);

  const fileSystem = downloads.fileSystem as MockFileSystem;
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/test.png')], ['image/png'], store);

  assert(mockChrome.fileManagerPrivate.getFileTaskCalledCount_ === 0);

  let tasks = await taskController.getFileTasks();
  assert(mockChrome.fileManagerPrivate.getFileTaskCalledCount_ === 1);
  assert(isSameEntries(tasks.entries, selectionHandler.selection.entries));
  // NOTE: It updates to the same file.
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/test.png')], ['image/png'], store);
  tasks = await taskController.getFileTasks();
  assert(mockChrome.fileManagerPrivate.getFileTaskCalledCount_ === 2);
  assert(isSameEntries(tasks.entries, selectionHandler.selection.entries));

  // The update above generates a new selection, even though it's updating to
  // the same file, this causes a new private API call.
  // The Store ActionsProducer debounces multiple concurrent calls for the same
  // file so in practice this shouldn't be a problem.
  const promise1 = taskController.getFileTasks();
  // Await 0ms to give time to promise1 to initialize.
  await new Promise(r => setTimeout(r));
  const promise2 = taskController.getFileTasks();
  const [tasks1, tasks2] = await Promise.all([promise1, promise2]);
  assertDeepEquals(
      tasks1.entries, tasks2.entries,
      'both tasks should have test.png as entry');
  assertTrue(tasks1 === tasks2);
  assert(mockChrome.fileManagerPrivate.getFileTaskCalledCount_ === 2);
  assert(isSameEntries(tasks1.entries, selectionHandler.selection.entries));

  // Check concurrent calls right after changing the selection.
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/hello.txt')], ['text/plain'], store);
  const promise3 = taskController.getFileTasks();
  // Await 0ms to give time to promise3 to initialize.
  await new Promise(r => setTimeout(r));
  const promise4 = taskController.getFileTasks();
  const [tasks3, tasks4] = await Promise.all([promise3, promise4]);
  assertDeepEquals(
      tasks3.entries, tasks4.entries,
      'both tasks should have hello.txt as entry');
  assert(isSameEntries(tasks3.entries, selectionHandler.selection.entries));
  assert(mockChrome.fileManagerPrivate.getFileTaskCalledCount_ === 3);
}


/**
 * Tests the file tasks in the store are updated each time the selected entries
 * are changed, including when there are no selected entries.
 */
export async function testFileTasksUpdatedAfterSelectionChange() {
  const selectionHandler = window.fileManager.selectionHandler;
  const store = getStore();
  const fileSystem = downloads.fileSystem as MockFileSystem;

  // Check no file tasks initially in the store.
  await waitForState(
      store,
      (st: State) =>
          st.currentDirectory?.selection.fileTasks.tasks !== undefined &&
          st.currentDirectory?.selection.fileTasks.tasks.length === 0);

  // Select entry.
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/test.png')], ['image/png'], store);
  // Check file tasks in store.
  await waitForState(
      store,
      (st: State) =>
          st.currentDirectory?.selection.fileTasks.tasks !== undefined &&
          st.currentDirectory?.selection.fileTasks.tasks.length > 0);

  // Select blank.
  selectionHandler.updateSelection([], [], store);
  // Check no file tasks in the store.
  await waitForState(
      store,
      (st: State) =>
          st.currentDirectory?.selection.fileTasks.tasks !== undefined &&
          st.currentDirectory?.selection.fileTasks.tasks.length === 0);
}

/**
 * Tests that getFileTasks() should always return the promise whose FileTasks
 * correspond to FileSelectionHandler.selection at the time getFileTasks() is
 * called.
 */
export async function testGetFileTasksShouldNotReturnObsoletePromise() {
  const selectionHandler = window.fileManager.selectionHandler;
  const store = getStore();
  const fileSystem = downloads.fileSystem as MockFileSystem;
  const taskController =
      createTaskController(selectionHandler as unknown as FileSelectionHandler);
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/test.png')], ['image/png'], store);

  let tasks = await taskController.getFileTasks();
  assert(isSameEntries(tasks.entries, selectionHandler.selection.entries));
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/testtest.jpg')], ['image/jpeg'],
      store);
  try {
    tasks = await taskController.getFileTasks();
    assert(isSameEntries(tasks.entries, selectionHandler.selection.entries));
  } catch (error) {
    assertNotReached(error!.toString());
  }
}

/**
 * Tests that changing the file selection during a getFileTasks() call causes
 * the getFileTasks() promise to reject.
 */
export async function testGetFileTasksShouldNotCacheRejectedPromise() {
  const selectionHandler = window.fileManager.selectionHandler;
  const store = getStore();
  const fileSystem = downloads.fileSystem as MockFileSystem;
  const taskController =
      createTaskController(selectionHandler as unknown as FileSelectionHandler);

  // Setup the selection handler computeAdditionalCallback to change the file
  // selection during the getFileTasks() call.
  let selectionUpdated = false;
  selectionHandler.computeAdditionalCallback = () => {
    // Update the selection.
    selectionHandler.updateSelection(
        [MockFileEntry.create(fileSystem, '/other-file.png')], ['image/png'],
        store);
    selectionUpdated = true;
  };

  // Set the initial selection.
  selectionHandler.updateSelection(
      [MockFileEntry.create(fileSystem, '/test.png')], ['image/png'], store);

  const tasks = await taskController.getFileTasks();
  assertTrue(selectionUpdated, 'selection should update');

  // Clears the selection handler computeAdditionalCallback so that the
  // promise won't be rejected during the getFileTasks() call.
  selectionHandler.computeAdditionalCallback = () => {};
  const callsToApi = mockChrome.fileManagerPrivate.getFileTaskCalledCount_;

  // Calling getFileTasks() in the same selection should not call the
  // private API.
  const tasks2 = await taskController.getFileTasks();
  assert(isSameEntries(tasks.entries, tasks2.entries));
  assert(isSameEntries(tasks2.entries, selectionHandler.selection.entries));

  // No more calls to the private API.
  assertEquals(
      callsToApi, mockChrome.fileManagerPrivate.getFileTaskCalledCount_);
  assertEquals(
      0,
      mockChrome.fileManagerPrivate.getFileTaskCalledEntries_
          .filter(
              (entries: Entry[]) =>
                  entries.filter((e: Entry) => e.name === '/test.png').length)
          .length,
      'Should have NO calls to private API for the initial selection');
}