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

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

import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {MockVolumeManager} from '../../background/js/mock_volume_manager.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import {createChild} from '../../common/js/dom_utils.js';
import {isInteractiveVolume} from '../../common/js/entry_utils.js';
import type {FakeEntry} from '../../common/js/files_app_entry_types.js';
import {FakeEntryImpl} from '../../common/js/files_app_entry_types.js';
import {installMockChrome} from '../../common/js/mock_chrome.js';
import {waitUntil} from '../../common/js/test_error_reporting.js';
import {str} from '../../common/js/translations.js';
import {FileErrorToDomError} from '../../common/js/util.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import {FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE, FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED, ODFS_EXTENSION_ID} from '../../foreground/js/constants.js';
import {clearSearch, updateSearch} from '../../state/ducks/search.js';
import {convertVolumeInfoAndMetadataToVolume} from '../../state/ducks/volumes.js';
import {createFakeVolumeMetadata, setUpFileManagerOnWindow, setupStore} from '../../state/for_tests.js';
import {PropStatus} from '../../state/state.js';
import {getEmptyState, getStore} from '../../state/store.js';

import type {DirectoryModel} from './directory_model.js';
import {EmptyFolderController, type ScanFailedEvent} from './empty_folder_controller.js';
import {FileListModel} from './file_list_model.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import {MockMetadataModel} from './metadata/mock_metadata.js';
import {createFakeDirectoryModel} from './mock_directory_model.js';
import {ProvidersModel} from './providers_model.js';

// Test class to enable accessing protected methods on the
// `EmptyFolderController`.
class TestEmptyFolderController extends EmptyFolderController {
  get isScanning() {
    return this.isScanning_;
  }

  set isScanning(isScanning: boolean) {
    this.isScanning_ = isScanning;
  }

  get label() {
    return this.label_;
  }

  onScanFailed(event: ScanFailedEvent) {
    return this.onScanFailed_(event);
  }

  updateUi() {
    this.updateUi_();
  }
}

let element: HTMLElement;
let directoryModel: DirectoryModel;
let providersModel: ProvidersModel;
let fileListModel: FileListModel;
let recentEntry: FakeEntry;
let emptyFolderController: TestEmptyFolderController;

export function setUp() {
  // Create EmptyFolderController instance with dependencies.
  element = document.createElement('div');
  createChild(element, 'label', 'span');

  // Setup the image, nested svg and nested use elements.
  const image = createChild(element, 'image');
  const svg = createChild(image, undefined, 'svg');
  createChild(svg, undefined, 'use');

  directoryModel = createFakeDirectoryModel();
  fileListModel =
      new FileListModel(new MockMetadataModel({}) as unknown as MetadataModel);
  directoryModel.getFileList = () => fileListModel;
  directoryModel.isSearching = () => false;
  providersModel = new ProvidersModel(new MockVolumeManager());
  recentEntry = new FakeEntryImpl(
      'Recent', RootType.RECENT,
      chrome.fileManagerPrivate.SourceRestriction.ANY_SOURCE,
      chrome.fileManagerPrivate.FileCategory.ALL);
  emptyFolderController = new TestEmptyFolderController(
      element, directoryModel, providersModel, recentEntry);
}

function addODFSToStore(): VolumeInfo {
  setUpFileManagerOnWindow();
  const initialState = getEmptyState();
  const {volumeManager} = window.fileManager;
  const odfsVolumeInfo = MockVolumeManager.createMockVolumeInfo(
      VolumeType.PROVIDED, 'odfs', 'odfs', 'odfs', ODFS_EXTENSION_ID);
  volumeManager.volumeInfoList.add(odfsVolumeInfo);
  const volume = convertVolumeInfoAndMetadataToVolume(
      odfsVolumeInfo, createFakeVolumeMetadata(odfsVolumeInfo));
  initialState.volumes[volume.volumeId] = volume;
  setupStore(initialState);

  return odfsVolumeInfo;
}

/**
 * Tests that no files message will be rendered for each filter type.
 */
export function testNoFilesMessage() {
  // Mock current directory to Recent.
  directoryModel.getCurrentRootType = () => RootType.RECENT;

  // For all filter.
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  assertEquals(
      str('RECENT_EMPTY_FOLDER'), emptyFolderController.label.innerText);
  // For audio filter.
  recentEntry.fileCategory = chrome.fileManagerPrivate.FileCategory.AUDIO;
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  assertEquals(
      str('RECENT_EMPTY_AUDIO_FOLDER'), emptyFolderController.label.innerText);
  // For document filter.
  recentEntry.fileCategory = chrome.fileManagerPrivate.FileCategory.DOCUMENT;
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  assertEquals(
      str('RECENT_EMPTY_DOCUMENTS_FOLDER'),
      emptyFolderController.label.innerText);
  // For image filter.
  recentEntry.fileCategory = chrome.fileManagerPrivate.FileCategory.IMAGE;
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  assertEquals(
      str('RECENT_EMPTY_IMAGES_FOLDER'), emptyFolderController.label.innerText);
  // For video filter.
  recentEntry.fileCategory = chrome.fileManagerPrivate.FileCategory.VIDEO;
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  assertEquals(
      str('RECENT_EMPTY_VIDEOS_FOLDER'), emptyFolderController.label.innerText);
}

/**
 * Tests that no files message will be hidden for non-Recent entries.
 */
export function testHiddenForNonRecent() {
  // Mock current directory to Downloads.
  directoryModel.getCurrentRootType = () => RootType.DOWNLOADS;

  emptyFolderController.updateUi();
  assertTrue(element.hidden);
  assertEquals('', emptyFolderController.label.innerText);
}

/**
 * Tests that no files message will be hidden if scanning is in progress.
 */
export function testHiddenForScanning() {
  // Mock current directory to Recent.
  directoryModel.getCurrentRootType = () => RootType.RECENT;
  // Mock scanning.
  emptyFolderController.isScanning = true;

  emptyFolderController.updateUi();
  assertTrue(element.hidden);
  assertEquals('', emptyFolderController.label.innerText);
}

/**
 * Tests that no files message will be hidden if there are files in the list.
 */
export function testHiddenForFiles() {
  // Mock current directory to Recent.
  directoryModel.getCurrentRootType = () => RootType.RECENT;
  // Current file list has 1 item.
  fileListModel.push(
      {name: 'a.txt', isDirectory: false, toURL: () => 'a.txt'} as Entry);

  emptyFolderController.updateUi();
  assertTrue(element.hidden);
  assertEquals('', emptyFolderController.label.innerText);
}

/**
 * Tests that the empty folder element is hidden and ODFS is still interactive
 * if the scan finished with no error. Add ODFS to the store so that the
 * |isInteractive| state of the volume can be read.
 */
export function testHiddenForODFSOnSuccess() {
  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Complete the scan with no error.
  emptyFolderController.onScanFinished();

  // Expect that the empty-folder element is hidden.
  assertTrue(element.hidden);
  assertEquals('', emptyFolderController.label.innerText);

  // Expect that ODFS is still interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));
}

// TODO(b/330786891): Remove this test once
// FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED is no longer needed for
// backwards compatibility with ODFS.
/**
 * Tests that the empty folder element is hidden and ODFS is still interactive
 * if the scan failed from a NO_MODIFICATION_ALLOWED_ERR (access denied) but
 * reauthentication is not required. Add ODFS to the store so that the
 * |isInteractive| state of the volume can be read.
 */
export async function testHiddenForODFSOnFailureWithoutReauthRequired() {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if reauth is required.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // Reauthentication is not required.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED,
          title: 'false',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Initialise the element to be shown to detect when it becomes hidden.
  element.hidden = false;

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Pass a NO_MODIFICATION_ALLOWED_ERR error (triggers a call to
  // getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is hidden. Need to wait for |updateUi|
  // (where the element is hidden) to be called as the check for
  // reauthentication is required is asynchronous.
  await waitUntil(() => element.hidden);
  assertEquals('', emptyFolderController.label.innerText);

  // Expect that ODFS is still interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));
}

/**
 * Tests that the empty folder element is hidden and ODFS is still interactive
 * if the scan failed from a NO_MODIFICATION_ALLOWED_ERR (access denied) but
 * reauthentication is not required. Add ODFS to the store so that the
 * |isInteractive| state of the volume can be read.
 */
export async function
testHiddenForODFSOnFailureWithoutReauthRequiredAccountState() {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if reauth is required.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // Reauthentication is not required.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE,
          title: 'NORMAL',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Initialise the element to be shown to detect when it becomes hidden.
  element.hidden = false;

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Pass a NO_MODIFICATION_ALLOWED_ERR error (triggers a call to
  // getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is hidden. Need to wait for |updateUi|
  // (where the element is hidden) to be called as the check for
  // reauthentication is required is asynchronous.
  await waitUntil(() => element.hidden);
  assertEquals('', emptyFolderController.label.innerText);

  // Expect that ODFS is still interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));
}

/**
 * Tests that the empty folder element is hidden and ODFS is still interactive
 * if the scan failed from a QUOTA_EXCEEDED_ERR but the account is not frozen.
 * Add ODFS to the store so that the |isInteractive| state of the volume can be
 * read.
 */
export async function testHiddenForODFSOnFailureWithoutFrozenState() {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if reauth is required.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // The account is not frozen.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE,
          title: 'NORMAL',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Initialise the element to be shown to detect when it becomes hidden.
  element.hidden = false;

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Pass a QUOTA_EXCEEDED_ERR error (triggers a call to getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.QUOTA_EXCEEDED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is hidden. Need to wait for |updateUi|
  // (where the element is hidden) to be called as the check for
  // the frozen state is asynchronous.
  await waitUntil(() => element.hidden);
  assertEquals('', emptyFolderController.label.innerText);

  // Expect that ODFS is still interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));
}

/**
 * Tests that the empty state image shows up when root type is Trash.
 */
export function testShownForTrash() {
  directoryModel.getCurrentRootType = () => RootType.TRASH;
  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  const text = emptyFolderController.label.innerText;
  assertTrue(text.includes(str('EMPTY_TRASH_FOLDER_TITLE')));
}

// TODO(b/330786891): Remove test once
// FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED is no longer needed
// for backwards compatibility with ODFS.
/**
 * Tests that the reauthentication required image shows up and ODFS becomes
 * non-interactive when the scan failed from a NO_MODIFICATION_ALLOWED_ERR
 * (access denied) and reauthentication is required. Add ODFS to the store so
 * that the |isInteractive| state of the volume can be set and read.
 */
export async function testShownForODFSOnFailureFromReauthReqWithOldAction(
    done: VoidCallback) {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if reauth is required.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // Reauthentication is required.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_REAUTHENTICATION_REQUIRED,
          title: 'true',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Initialise the element to be hidden to detect when it becomes shown.
  element.hidden = true;

  // Pass a NO_MODIFICATION_ALLOWED_ERR error (triggers a call to
  // getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is shown and the sign in link is
  // present. Need to wait for |updateUi| (where the element is shown) to be
  // called as the check for reauthentication is required is asynchronous.
  await waitUntil(() => !element.hidden);
  await waitUntil(
      () => emptyFolderController.label.querySelector('.sign-in') !== null);

  // Expect that ODFS is non-interactive.
  assertFalse(isInteractiveVolume(odfsVolumeInfo));
  done();
}

/**
 * Tests that the reauthentication required image shows up and ODFS becomes
 * non-interactive when the scan failed from a NO_MODIFICATION_ALLOWED_ERR
 * (access denied) and reauthentication is required. Add ODFS to the store so
 * that the |isInteractive| state of the volume can be set and read.
 */
export async function testShownForODFSOnFailureFromReauthReq(
    done: VoidCallback) {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if reauth is required.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // Reauthentication is required.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE,
          title: 'REAUTHENTICATION_REQUIRED',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Initialise the element to be hidden to detect when it becomes shown.
  element.hidden = true;

  // Pass a NO_MODIFICATION_ALLOWED_ERR error (triggers a call to
  // getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.NO_MODIFICATION_ALLOWED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is shown and the sign in link is
  // present. Need to wait for |updateUi| (where the element is shown) to be
  // called as the check for reauthentication is required is asynchronous.
  await waitUntil(() => !element.hidden);
  await waitUntil(
      () => emptyFolderController.label.querySelector('.sign-in') !== null);

  // Expect that ODFS is non-interactive.
  assertFalse(isInteractiveVolume(odfsVolumeInfo));
  done();
}

/**
 * Tests that the frozen account image shows up and ODFS becomes
 * non-interactive when the scan failed from a QUOTA_EXCEEDED_ERR and the user
 * has a frozen account. Add ODFS to the store so that the |isInteractive| state
 * of the volume can be set and read.
 */
export async function testShownForODFSOnFailureFromFrozenAccount(
    done: VoidCallback) {
  // Mock fileManagerPrivate.getCustomActions which is called when determining
  // if the account is frozen.
  const mockChrome = {
    fileManagerPrivate: {
      getCustomActions: function(
          _: Entry[],
          callback: (customActions: chrome.fileManagerPrivate
                         .FileSystemProviderAction[]) => void) {
        // The account is frozen.
        const actions = [{
          id: FSP_ACTION_HIDDEN_ONEDRIVE_ACCOUNT_STATE,
          title: 'FROZEN_ACCOUNT',
        }];
        callback(actions);
      },
    },
  };
  installMockChrome(mockChrome);

  const odfsVolumeInfo = addODFSToStore();

  // Set ODFS as the volume.
  directoryModel.getCurrentVolumeInfo = function() {
    return odfsVolumeInfo;
  };

  // Expect that ODFS is interactive.
  assertTrue(isInteractiveVolume(odfsVolumeInfo));

  // Initialise the element to be hidden to detect when it becomes shown.
  element.hidden = true;

  // Pass a QUOTA_EXCEEDED_ERR error (triggers a call to
  // getCustomActions).
  const event = new CustomEvent('cur-dir-scan-failed', {
    detail: {
      error: {
        name: FileErrorToDomError.QUOTA_EXCEEDED_ERR,
        message: '',
      },
    },
  });
  emptyFolderController.onScanFailed(event);

  // Expect that the empty-folder element is shown and the frozen account text
  // is present. Need to wait for |updateUi| (where the element is shown) to be
  // called as the check for a frozen account is asynchronous.
  await waitUntil(() => !element.hidden);
  await waitUntil(
      () => emptyFolderController.label.innerHTML.includes(
          str('ONEDRIVE_FROZEN_ACCOUNT_SUBTITLE')));

  // Expect that ODFS is non-interactive.
  assertFalse(isInteractiveVolume(odfsVolumeInfo));
  done();
}

/**
 * Tests that the empty state image shows up when search is active.
 */
export function testShowNoSearchResult() {
  const store = getStore();
  store.init(getEmptyState());
  // Test 1: Store indicates we are not searching. No matter if the directory is
  // empty or not, we must not show "No matching search results" panel.
  emptyFolderController.updateUi();
  assertTrue(element.hidden);

  // Test 2: Dispatch search update so that the store indicates we are
  // searchhing. Expect "No matching search results" panel.
  store.dispatch(updateSearch({
    query: 'any-string-will-do',
    status: PropStatus.STARTED,
    options: undefined,
  }));

  emptyFolderController.updateUi();
  assertFalse(element.hidden);
  const text = emptyFolderController.label.innerText;
  assertTrue(text.includes(str('SEARCH_NO_MATCHING_RESULTS_TITLE')));

  // Clean up the store.
  store.dispatch(clearSearch());
}