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

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

import {loadTimeData} from 'chrome://resources/ash/common/load_time_data.m.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import type {EntryLocation} from '../../background/js/entry_location_impl.js';
import {createCrostiniForTest} from '../../background/js/mock_crostini.js';
import {MockProgressCenter} from '../../background/js/mock_progress_center.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {installMockChrome, MockMetrics} from '../../common/js/mock_chrome.js';
import {MockFileEntry, MockFileSystem} from '../../common/js/mock_entry.js';
import {ProgressItemState} from '../../common/js/progress_center_common.js';
import {LEGACY_FILES_EXTENSION_ID} from '../../common/js/url_constants.js';
import {descriptorEqual} from '../../common/js/util.js';
import {RootType, VolumeError, VolumeType} from '../../common/js/volume_manager_types.js';
import type {XfPasswordDialog} from '../../widgets/xf_password_dialog.js';
import {USER_CANCELLED} from '../../widgets/xf_password_dialog.js';

import type {DirectoryModel} from './directory_model.js';
import {type DirectoryChangeTracker} from './directory_model.js';
import type {FileManager} from './file_manager.js';
import {FileTasks} from './file_tasks.js';
import type {FileTransferController} from './file_transfer_controller.js';
import {MetadataItem} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import type {TaskController} from './task_controller.js';
import type {TaskHistory} from './task_history.js';
import type {DefaultTaskDialog} from './ui/default_task_dialog.js';
import type {ImportCrostiniImageDialog} from './ui/import_crostini_image_dialog.js';
import type {InstallLinuxPackageDialog} from './ui/install_linux_package_dialog.js';

let passwordDialog: XfPasswordDialog;

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

/** Mock to keep track of the calls to metricsPrivate. */
let mockMetrics: MockMetrics;

/** Mock task history. */
const mockTaskHistory = {
  getLastExecutedTime: function(_descriptor: any) {
    return 0;
  },
  recordTaskExecuted: function(_descriptor: any) {},
} as unknown as TaskHistory;

/** Mock file transfer controller. */
const mockFileTransferController = {} as unknown as FileTransferController;

/** Mock directory change tracker. */
const fakeTracker = {
  hasChange: false,
} as unknown as DirectoryChangeTracker;

/** Fake url for mounted ZIP file. */
const fakeMountedZipUrl: string =
    'filesystem:chrome-extension://hhaomjibdihmijegdhdafkllkbggdgoj/' +
    'external/fakePath/test.zip';

/** Panel IDs for ZIP mount operations. */
const zipMountPanelId = 'Mounting: ' + fakeMountedZipUrl;

/** Panel IDs for ZIP mount errors.  */
const errorZipMountPanelId = 'Cannot mount: ' + fakeMountedZipUrl;

// Set up test components.
export function setUp() {
  // Override the translations used in the tests to make easier to compare.
  loadTimeData.overrideValues({
    NO_TASK_FOR_EXECUTABLE: 'NO_TASK_FOR_EXECUTABLE',
    NO_TASK_FOR_DMG: 'NO_TASK_FOR_DMG',
    NO_TASK_FOR_CRX_TITLE: 'NO_TASK_FOR_CRX_TITLE',
    NO_TASK_FOR_CRX: 'NO_TASK_FOR_CRX',
    NO_TASK_FOR_FILE: 'NO_TASK_FOR_FILE',
  });
  const mockTask = {
    descriptor: {
      appId: 'handler-extension-id',
      taskType: 'app',
      actionId: 'any',
    },
    isDefault: false,
    isGenericFileHandler: true,
  } as unknown as chrome.fileManagerPrivate.FileTask;

  mockMetrics = new MockMetrics();

  // Mock chome APIs.
  mockChrome = {
    metricsPrivate: mockMetrics,
    fileManagerPrivate: {
      getFileTasks: function(
          _entries: Entry[], _sourceUrls: string[],
          callback: (tasks: any) => void) {
        setTimeout(callback.bind(null, {tasks: [mockTask]}), 0);
      },
      executeTask: function(
          _descriptor: any, _entries: any,
          onViewFiles: (result: chrome.fileManagerPrivate.TaskResult) => void) {
        onViewFiles(chrome.fileManagerPrivate.TaskResult.FAILED);
      },
      sharePathsWithCrostini: function(
          _vmName: any, _entries: Entry[], _persist: any,
          callback: () => void) {
        callback();
      },
    },
  };

  installMockChrome(mockChrome);
}

/**
 * Fail with an error message.
 * @param message The error message.
 * @param details Optional details.
 */
function failWithMessage(message: string, details?: string) {
  if (details) {
    message += ': '.concat(details);
  }
  throw new Error(message);
}

/** Returns mocked file manager components. */
function getMockFileManager(): FileManager {
  const crostini = createCrostiniForTest();

  passwordDialog = {} as unknown as XfPasswordDialog;
  const fileManager = {
    volumeManager: {
      getLocationInfo: function(_entry: Entry) {
        return {
          rootType: RootType.DRIVE,
        };
      },
      getDriveConnectionState: function() {
        return chrome.fileManagerPrivate.DriveConnectionStateType;
      },
      getVolumeInfo: function(_entry: Entry) {
        return {
          volumeType: VolumeType.DRIVE,
        };
      },
    },
    ui: {
      alertDialog: {
        showHtml: function(
            _title: string, _text: string, _onOk: () => void,
            _onCancel: () => void, _onShow: () => void) {},
      },
      passwordDialog,
      speakA11yMessage: (_text: string) => {},
    },
    metadataModel: {
      getCache: function(_entries: Entry[], _names: string[]) {
        return _entries.map(_ => new MetadataItem());
      },
    } as unknown as MetadataModel,
    directoryModel: {
      getCurrentRootType: function() {
        return null;
      },
      changeDirectoryEntry: function(_displayRoot: Entry) {},
    } as unknown as DirectoryModel,
    crostini: crostini,
    progressCenter: new MockProgressCenter(),
    taskController: {
      createItems(_fileTasks: FileTasks) {},
    } as unknown as TaskController,
  };

  fileManager.crostini.initVolumeManager(
      fileManager.volumeManager as unknown as VolumeManager);
  return fileManager as unknown as FileManager;
}

/**
 * Returns a promise that resolves when the showHtml method of alert dialog is
 * called with the expected title and text.
 */
function showHtmlOfAlertDialogIsCalled(
    entries: Entry[], expectedTitle: string,
    expectedText: string): Promise<void> {
  return new Promise((resolve) => {
    const fileManager = getMockFileManager();
    fileManager.ui.alertDialog.showHtml =
        (title, text, _onOk, _onCancel, _onShow) => {
          assertEquals(expectedTitle, title);
          assertEquals(expectedText, text);
          resolve();
        };

    FileTasks
        .create(
            fileManager.volumeManager, fileManager.metadataModel,
            fileManager.directoryModel, fileManager.ui,
            mockFileTransferController, entries, mockTaskHistory,
            fileManager.crostini, fileManager.progressCenter,
            fileManager.taskController)
        .then(tasks => {
          tasks.executeDefault();
        });
  });
}

/**
 * Returns a promise that resolves when the task picker is called.
 */
function showDefaultTaskDialogCalled(entries: Entry[]): Promise<void> {
  return new Promise((resolve) => {
    const fileManager = getMockFileManager();
    fileManager.ui.defaultTaskPicker = {
      showDefaultTaskDialog: function(
          _title, _message, _items, _defaultIdx, _onSuccess) {
        resolve();
      },
    } as DefaultTaskDialog;

    FileTasks
        .create(
            fileManager.volumeManager, fileManager.metadataModel,
            fileManager.directoryModel, fileManager.ui,
            mockFileTransferController, entries, mockTaskHistory,
            fileManager.crostini, fileManager.progressCenter,
            fileManager.taskController)
        .then(tasks => {
          tasks.executeDefault();
        });
  });
}

/**
 * Returns a promise that resolves when showImportCrostiniImageDialog is called.
 */
async function showImportCrostiniImageDialogIsCalled(entries: Entry[]):
    Promise<void> {
  return new Promise((resolve) => {
    const fileManager = getMockFileManager();
    fileManager.ui.importCrostiniImageDialog = {
      showImportCrostiniImageDialog: (_entry: Entry) => {
        resolve();
      },
    } as ImportCrostiniImageDialog;

    FileTasks
        .create(
            fileManager.volumeManager, fileManager.metadataModel,
            fileManager.directoryModel, fileManager.ui,
            mockFileTransferController, entries, mockTaskHistory,
            fileManager.crostini, fileManager.progressCenter,
            fileManager.taskController)
        .then(tasks => {
          tasks.executeDefault();
        });
  });
}

/** Tests opening a .exe file. */
export async function testToOpenExeFile(done: () => void) {
  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.exe');

  await showHtmlOfAlertDialogIsCalled(
      [mockEntry], 'test.exe', 'NO_TASK_FOR_EXECUTABLE');
  done();
}

/** Tests opening a .dmg file. */
export async function testToOpenDmgFile(done: () => void) {
  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.dmg');

  await showHtmlOfAlertDialogIsCalled(
      [mockEntry], 'test.dmg', 'NO_TASK_FOR_DMG');
  done();
}

/**
 * Tests opening a .crx file.
 */
export async function testToOpenCrxFile(done: () => void) {
  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.crx');

  await showHtmlOfAlertDialogIsCalled(
      [mockEntry], 'NO_TASK_FOR_CRX_TITLE', 'NO_TASK_FOR_CRX');
  done();
}

/** Tests opening a .rtf file. */
export async function testToOpenRtfFile(done: () => void) {
  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.rtf');

  await showHtmlOfAlertDialogIsCalled(
      [mockEntry], 'test.rtf', 'NO_TASK_FOR_FILE');
  done();
}

/**
 * Tests opening the task picker with an entry that does not have a default app
 * but there are multiple apps that could open it.
 */
export async function testOpenTaskPicker(done: () => void) {
  chrome.fileManagerPrivate.getFileTasks =
      (_entries: Entry[], _sourceUrls: string[],
       callback: (tasks: any) => void) => {
        setTimeout(
            callback.bind(null, {
              tasks: [
                {
                  descriptor: {
                    appId: 'handler-extension-id1',
                    taskType: 'app',
                    actionId: 'any',
                  },
                  isDefault: false,
                  isGenericFileHandler: false,
                  title: 'app 1',
                },
                {
                  descriptor: {
                    appId: 'handler-extension-id2',
                    taskType: 'app',
                    actionId: 'any',
                  },
                  isDefault: false,
                  isGenericFileHandler: false,
                  title: 'app 2',
                },
              ],
            }),
            0);
      };

  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.tiff');

  await showDefaultTaskDialogCalled([mockEntry]);
  done();
}

/**
 * Tests opening the task picker with an entry that does not have a default app
 * but there are multiple apps that could open it. The app with the most recent
 * task execution order should execute.
 */
export async function testOpenWithMostRecentlyExecuted(done: () => void) {
  const latestTaskDescriptor = {
    appId: 'handler-extension-most-recently-executed',
    taskType: 'app',
    actionId: 'any',
  };
  const oldTaskDescriptor = {
    appId: 'handler-extension-executed-before',
    taskType: 'app',
    actionId: 'any',
  };

  chrome.fileManagerPrivate.getFileTasks =
      (_entries: Entry[], _sourceUrls: string[],
       callback: (tasks: chrome.fileManagerPrivate.ResultingTasks) => void) => {
        setTimeout(
            callback.bind(
                null,
                // File tasks is sorted by last executed time, latest first.
                {
                  tasks: [
                    {
                      descriptor: latestTaskDescriptor,
                      isDefault: false,
                      isGenericFileHandler: false,
                      title: 'app 1',
                    },
                    {
                      descriptor: oldTaskDescriptor,
                      isDefault: false,
                      isGenericFileHandler: false,
                      title: 'app 2',
                    },
                    {
                      descriptor: {
                        appId: 'handler-extension-never-executed',
                        taskType: 'app',
                        actionId: 'any',
                      },
                      isDefault: false,
                      isGenericFileHandler: false,
                      title: 'app 3',
                    },
                  ],
                } as chrome.fileManagerPrivate.ResultingTasks),
            0);
      };

  const taskHistory = {
    getLastExecutedTime: function(
        descriptor: chrome.fileManagerPrivate.FileTaskDescriptor) {
      if (descriptorEqual(descriptor, oldTaskDescriptor)) {
        return 10000;
      }
      if (descriptorEqual(descriptor, latestTaskDescriptor)) {
        return 20000;
      }
      return 0;
    },
    recordTaskExecuted: function(
        _descriptor: chrome.fileManagerPrivate.FileTaskDescriptor) {},
  };

  type FileTaskDescriptor = chrome.fileManagerPrivate.FileTaskDescriptor;
  let executedTask: FileTaskDescriptor|null = null;
  chrome.fileManagerPrivate.executeTask =
      (descriptor: FileTaskDescriptor, _entries: Entry[],
       onViewFiles: (result: chrome.fileManagerPrivate.TaskResult) => void) => {
        executedTask = descriptor;
        onViewFiles(chrome.fileManagerPrivate.TaskResult.OPENED);
      };

  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.tiff');

  const fileManager = getMockFileManager();
  fileManager.ui.defaultTaskPicker = {
    showDefaultTaskDialog: function(
        _title, _message, _items, _defaultIdx, _onSuccess) {
      failWithMessage('should not show task picker');
    },
  } as DefaultTaskDialog;

  const tasks = await FileTasks.create(
      fileManager.volumeManager, fileManager.metadataModel,
      fileManager.directoryModel, fileManager.ui, mockFileTransferController,
      [mockEntry], taskHistory as TaskHistory, fileManager.crostini,
      fileManager.progressCenter, fileManager.taskController);
  await tasks.executeDefault();
  assertTrue(descriptorEqual(latestTaskDescriptor, executedTask!));

  done();
}

function setUpInstallLinuxPackage() {
  const fileManager = getMockFileManager();
  fileManager.volumeManager.getLocationInfo = (_entry): EntryLocation => {
    return {
      rootType: RootType.CROSTINI,
    } as unknown as EntryLocation;
  };
  const fileTask = {
    descriptor: {
      appId: LEGACY_FILES_EXTENSION_ID,
      taskType: 'app',
      actionId: 'install-linux-package',
    },
    isDefault: false,
    isGenericFileHandler: false,
    title: '__MSG_INSTALL_LINUX_PACKAGE__',
  };
  chrome.fileManagerPrivate.getFileTasks =
      (_entries: Entry[], _sourceUrls: string[],
       callback: (tasks: any) => void) => {
        setTimeout(callback.bind(null, {tasks: [fileTask]}), 0);
      };
  return fileManager;
}

/**
 * Tests opening a .deb file. The crostini linux package install dialog should
 * be called.
 */
export async function testOpenInstallLinuxPackageDialog(done: () => void) {
  const fileManager = setUpInstallLinuxPackage();
  const mockFileSystem = new MockFileSystem('volumeId');
  const mockEntry = MockFileEntry.create(mockFileSystem, '/test.deb');

  await new Promise<void>(async (resolve) => {
    fileManager.ui.installLinuxPackageDialog = {
      showInstallLinuxPackageDialog: function(_entry: Entry) {
        resolve();
      },
    } as unknown as InstallLinuxPackageDialog;

    const tasks = await FileTasks.create(
        fileManager.volumeManager, fileManager.metadataModel,
        fileManager.directoryModel, fileManager.ui, mockFileTransferController,
        [mockEntry], mockTaskHistory, fileManager.crostini,
        fileManager.progressCenter, fileManager.taskController);
    await tasks.executeDefault();
  });

  done();
}

/**
 * Tests opening a .tini file. The import crostini image dialog should be
 * called.
 */
export async function testToOpenTiniFileOpensImportCrostiniImageDialog(
    done: () => void) {
  chrome.fileManagerPrivate.getFileTasks =
      (_entries: Entry[], _sourceUrls: string[],
       callback: (tasks: any) => void) => {
        setTimeout(
            callback.bind(null, {
              tasks: [
                {
                  descriptor: {
                    appId: LEGACY_FILES_EXTENSION_ID,
                    taskType: 'app',
                    actionId: 'import-crostini-image',
                  },
                  isDefault: false,
                  isGenericFileHandler: false,
                },
              ],
            }),
            0);
      };

  const mockEntry =
      MockFileEntry.create(new MockFileSystem('testfilesystem'), '/test.tini');

  await showImportCrostiniImageDialogIsCalled([mockEntry]);
  done();
}

/**
 * Checks that the function that returns a file type for file entry handles
 * correctly identifies files with known and unknown extensions.
 */
export function testGetViewFileType() {
  const mockFileSystem = new MockFileSystem('volumeId');
  const testData = [
    {extension: 'log', expected: '.log'},
    {extension: '__unknown_extension__', expected: 'other'},
  ];
  for (const data of testData) {
    const mockEntry =
        MockFileEntry.create(mockFileSystem, `/report.${data.extension}`);
    const type = FileTasks.getViewFileType(mockEntry);
    assertEquals(data.expected, type);
  }
}

/**
 * Checks that the progress center is properly updated when mounting archives
 * successfully.
 */
export async function testMountArchiveAndChangeDirectoryNotificationSuccess(
    done: () => void) {
  const fileManager = getMockFileManager();

  // Define FileTasks instance.
  const tasks = await FileTasks.create(
      fileManager.volumeManager, fileManager.metadataModel,
      fileManager.directoryModel, fileManager.ui, mockFileTransferController,
      [], mockTaskHistory, fileManager.crostini, fileManager.progressCenter,
      fileManager.taskController);

  fileManager.volumeManager!.mountArchive =
      async function(_url: string, _password: string): Promise<VolumeInfo> {
    // Check: progressing state.
    assertEquals(
        ProgressItemState.PROGRESSING,
        fileManager.progressCenter.getItemById(zipMountPanelId)?.state);

    const volumeInfo = {resolveDisplayRoot: () => null};
    return volumeInfo as unknown as VolumeInfo;
  };

  // Mount archive.
  await tasks['mountArchiveAndChangeDirectory_'](
      fakeTracker, fakeMountedZipUrl);

  // Check: mount completed, no error.
  assertEquals(
      ProgressItemState.COMPLETED,
      fileManager.progressCenter.getItemById(zipMountPanelId)?.state);
  assertEquals(
      undefined, fileManager.progressCenter.getItemById(errorZipMountPanelId));

  // Check: a zip mount time UMA has been recorded.
  assertTrue('FileBrowser.ZipMountTime.Other' in mockMetrics.metricCalls);

  done();
}

/**
 * Checks that the progress center is properly updated when mounting an archive
 * resolves with an error.
 */
export async function
testMountArchiveAndChangeDirectoryNotificationInvalidArchive(done: () => void) {
  const fileManager = getMockFileManager();

  // Define FileTasks instance.
  const tasks = await FileTasks.create(
      fileManager.volumeManager, fileManager.metadataModel,
      fileManager.directoryModel, fileManager.ui, mockFileTransferController,
      [], mockTaskHistory, fileManager.crostini, fileManager.progressCenter,
      fileManager.taskController);

  fileManager.volumeManager.mountArchive = function(_url, _password) {
    return Promise.reject(VolumeError.INTERNAL_ERROR);
  };

  // Mount archive.
  await tasks['mountArchiveAndChangeDirectory_'](
      fakeTracker, fakeMountedZipUrl);

  // Check: mount is completed with an error.
  assertEquals(
      ProgressItemState.COMPLETED,
      fileManager.progressCenter.getItemById(zipMountPanelId)?.state);
  assertEquals(
      ProgressItemState.ERROR,
      fileManager.progressCenter.getItemById(errorZipMountPanelId)?.state);

  // Check: no zip mount time UMA has been recorded since mounting the archive
  // failed.
  assertFalse('FileBrowser.ZipMountTime.Other' in mockMetrics.metricCalls);

  done();
}

/**
 * Checks that the progress center is properly updated when the password prompt
 * for an encrypted archive is canceled.
 */
export async function
testMountArchiveAndChangeDirectoryNotificationCancelPassword(done: () => void) {
  const fileManager = getMockFileManager();

  // Define FileTasks instance.
  const tasks = await FileTasks.create(
      fileManager.volumeManager, fileManager.metadataModel,
      fileManager.directoryModel, fileManager.ui, mockFileTransferController,
      [], mockTaskHistory, fileManager.crostini, fileManager.progressCenter,
      fileManager.taskController);

  fileManager.volumeManager.mountArchive = function(_url, _password) {
    return Promise.reject(VolumeError.NEED_PASSWORD);
  };

  passwordDialog.askForPassword =
      async function(_filename: string, _password: string|null = null) {
    return Promise.reject(USER_CANCELLED);
  };

  // Mount archive.
  await tasks['mountArchiveAndChangeDirectory_'](
      fakeTracker, fakeMountedZipUrl);

  // Check: mount is completed, no error since the user canceled the password
  // prompt.
  assertEquals(
      ProgressItemState.COMPLETED,
      fileManager.progressCenter.getItemById(zipMountPanelId)?.state);
  assertEquals(
      undefined, fileManager.progressCenter.getItemById(errorZipMountPanelId));

  // Check: no zip mount time UMA has been recorded since the mount has been
  // cancelled.
  assertFalse('FileBrowser.ZipMountTime.Other' in mockMetrics.metricCalls);

  done();
}

/**
 * Checks that the progress center is properly updated when mounting an
 * encrypted archive.
 */
export async function
testMountArchiveAndChangeDirectoryNotificationEncryptedArchive(
    done: () => void) {
  const fileManager = getMockFileManager();

  // Define FileTasks instance.
  const tasks = await FileTasks.create(
      fileManager.volumeManager, fileManager.metadataModel,
      fileManager.directoryModel, fileManager.ui, mockFileTransferController,
      [], mockTaskHistory, fileManager.crostini, fileManager.progressCenter,
      fileManager.taskController);

  fileManager.volumeManager.mountArchive = function(
      _url, password: string|null) {
    return new Promise<VolumeInfo>((resolve, reject) => {
      if (password) {
        assertEquals('testpassword', password);
        const volumeInfo = {resolveDisplayRoot: () => null};
        resolve(volumeInfo as unknown as VolumeInfo);
      } else {
        reject(VolumeError.NEED_PASSWORD);
      }
    });
  };

  passwordDialog.askForPassword =
      async function(_filename: string, _password: string|null = null) {
    return Promise.resolve('testpassword');
  };

  // Mount archive.
  await tasks['mountArchiveAndChangeDirectory_'](
      fakeTracker, fakeMountedZipUrl);

  // Check: mount is completed, no error since the user entered a valid
  // password.
  assertEquals(
      ProgressItemState.COMPLETED,
      fileManager.progressCenter.getItemById(zipMountPanelId)?.state);
  assertEquals(
      undefined, fileManager.progressCenter.getItemById(errorZipMountPanelId));

  done();
}