chromium/chrome/test/data/webui/chromeos/manage_mirrorsync/manage_mirrorsync_app_test.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 'chrome://manage-mirrorsync/components/manage_mirrorsync.js';
import 'chrome://webui-test/chromeos/mojo_webui_test_support.js';

import {BrowserProxy} from 'chrome://manage-mirrorsync/browser_proxy.js';
import {FolderSelector} from 'chrome://manage-mirrorsync/components/folder_selector.js';
import {PageHandlerRemote} from 'chrome://manage-mirrorsync/manage_mirrorsync.mojom-webui.js';
import {assert} from 'chrome://resources/js/assert.js';
import {assertArrayEquals, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {TestBrowserProxy} from 'chrome://webui-test/test_browser_proxy.js';
import {TestMock} from 'chrome://webui-test/test_mock.js';
import {isVisible} from 'chrome://webui-test/test_util.js';

/**
 * A fake BrowserProxy implementation that enables switching out the real one to
 * mock various mojo responses.
 */
class ManageMirrorSyncTestBrowserProxy extends TestBrowserProxy implements
    BrowserProxy {
  handler: TestMock<PageHandlerRemote>&PageHandlerRemote;

  constructor() {
    super(['getChildFolders']);
    this.handler = TestMock.fromClass(PageHandlerRemote);
  }
}

/**
 * Wait until the supplied function evaluates to true, repeating evaluation
 * every 100ms for a total time of 5s.
 */
async function waitUntil(func: () => boolean) {
  let promiseResolve: (value: null) => void;
  let promiseReject: (error: Error) => void;
  const promise = new Promise((resolve, reject) => {
    promiseResolve = resolve;
    promiseReject = reject;
  });
  const interval = setInterval(() => {
    if (func()) {
      clearInterval(interval);
      promiseResolve(null);
    }
  }, 100);
  setTimeout(() => {
    clearInterval(interval);
    promiseReject(new Error('waitUntil has timed out'));
  }, 5000);
  return promise;
}

/**
 * Helper to sanitize a path by ensuring double quote characters are properly
 * escaped.
 */
function sanitizePath(path: string): string {
  return path.replace(/"/g, '\\"');
}

suite('<manage-mirrorsync>', () => {
  /* Holds the <manage-mirrorsync> app */
  let appHolder: HTMLDivElement;
  /* The <manage-mirrorsync> app, this gets cleared before every test */
  let manageMirrorSyncApp: HTMLElement;
  /* The BrowserProxy element to make assertions on when methods are called */
  let testProxy: ManageMirrorSyncTestBrowserProxy;

  /**
   * Runs prior to all the tests running, attaches a div to enable isolated
   * clearing and attaching of the web component.
   */
  suiteSetup(() => {
    appHolder = document.createElement('div');
    document.body.appendChild(appHolder);
  });

  /**
   * Runs before every test. Ensures the DOM is clear of any existing
   * <manage-mirrorsync> components.
   */
  setup(() => {
    assert(window.trustedTypes);
    appHolder.innerHTML = window.trustedTypes.emptyHTML;
    testProxy = new ManageMirrorSyncTestBrowserProxy();
    BrowserProxy.setInstance(testProxy);
    manageMirrorSyncApp = document.createElement('manage-mirrorsync');
    appHolder.appendChild(manageMirrorSyncApp);
  });

  /**
   * Runs after every test. Removes all elements from the <div> added to hold
   * the <manage-mirrorsync> component.
   */
  teardown(() => {
    appHolder.innerHTML = window.trustedTypes!.emptyHTML;
  });

  /**
   * Helper function to run a querySelector over the MirrorSyncApp shadowRoot
   * and assert non-nullness.
   */
  function queryMirrorSyncShadowRoot(selector: string): HTMLElement|null {
    return manageMirrorSyncApp!.shadowRoot!.querySelector(selector);
  }

  /**
   * Returns the <folder-selector> element on the page.
   */
  function getFolderSelector(): FolderSelector {
    return (queryMirrorSyncShadowRoot('folder-selector')! as FolderSelector);
  }

  /**
   * Queries elements in the <folder-selector> shadowRoot.
   */
  function queryFolderSelectorShadowRoot(selector: string): HTMLElement|null {
    return getFolderSelector().shadowRoot!.querySelector(selector);
  }

  /**
   * Show the <folder-selector> element by selecting the "Sync selected files or
   * folders" button.
   */
  async function showFolderSelector(): Promise<void> {
    // <folder-selection> should be hidden to call this method.
    assertTrue(getFolderSelector().hidden);

    // Click the "Sync selected files or folders" button to show the folder
    // hierarchy web component.
    queryMirrorSyncShadowRoot('#selected')!.click();

    // Ensure the <folder-selector> element is visible after pressing the
    // checkbox.
    await waitUntil(() => getFolderSelector().hidden === false);
  }

  /**
   * Queries all the input elements with data-full-path attributes. Elements
   * with attributes but empty OR are not visible in the viewport are excluded.
   * Returns a string array of the data-full-path values.
   */
  function getAllVisiblePaths(): string[] {
    const elements = getFolderSelector().shadowRoot!.querySelectorAll(
        'input[data-full-path]');
    if (elements.length === 0) {
      return [];
    }
    const paths: string[] = [];
    for (const element of elements) {
      const dataFullPath = element.getAttribute('data-full-path');
      if (!dataFullPath || !isVisible(element)) {
        continue;
      }
      paths.push(dataFullPath!);
    }
    return paths;
  }

  /**
   * Wait until the visible paths are the supplied paths then assert that they
   * actually match the `expectedPaths`.
   */
  async function waitAndAssertVisiblePaths(expectedPaths: string[]):
      Promise<void> {
    await waitUntil(() => getAllVisiblePaths().length === expectedPaths.length);
    assertArrayEquals(getAllVisiblePaths(), expectedPaths);
  }

  function getInputElement(path: string): HTMLInputElement {
    const element = queryFolderSelectorShadowRoot(
        `input[data-full-path="${sanitizePath(path)}"]`);
    assertNotEquals(element, null);
    return (element as HTMLInputElement);
  }

  /**
   * Helper method to expand a particular node in the tree to show all its
   * children.
   */
  function expandPath(path: string) {
    const input = getInputElement(path);
    const liElement = input.parentElement as HTMLLIElement;
    liElement.click();
  }

  test(
      'checking individual folder selection shows folder hierarchy',
      async () => {
        // Set result to return an empty array of paths.
        testProxy.handler.setResultFor('getChildFolders', {paths: []});

        // Show the <folder-selector> element.
        await showFolderSelector();

        // The only rendered path should be the root one.
        await waitAndAssertVisiblePaths(['/']);
      });

  test(
      'children of root should be rendered, but not their descendants',
      async () => {
        // Set result to return an empty array of paths.
        testProxy.handler.setResultFor(
            'getChildFolders',
            {paths: [{path: '/foo'}, {path: '/foo/bar'}, {path: '/baz'}]});

        // Show the <folder-selector> element.
        await showFolderSelector();

        // All top-level paths should be visible on startup, but `/foo/bar`
        // should not be visible.
        await waitAndAssertVisiblePaths(['/', '/foo', '/baz']);
      });

  test('when expanding an element its children should be visible', async () => {
    // Set result to return an empty array of paths.
    testProxy.handler.setResultFor(
        'getChildFolders', {paths: [{path: '/foo'}, {path: '/foo/bar'}]});

    // Show the <folder-selector> element.
    await showFolderSelector();

    // All top-level paths should be visible on startup, but `/foo/bar` should
    // not be visible.
    await waitAndAssertVisiblePaths(['/', '/foo']);

    // Expand the /foo path which should make the /foo/bar path visible.
    expandPath('/foo');

    // After expanding expect that /foo/bar is now visible in the DOM.
    await waitAndAssertVisiblePaths(['/', '/foo', '/foo/bar']);
  });

  test(
      'selecting a path should make its children disabled on expansion',
      async () => {
        // Set result to return an empty array of paths.
        testProxy.handler.setResultFor(
            'getChildFolders', {paths: [{path: '/foo'}, {path: '/foo/bar'}]});

        // Show the <folder-selector> element.
        await showFolderSelector();

        // All top-level paths should be visible on startup, but `/foo/bar`
        // should not be visible.
        await waitAndAssertVisiblePaths(['/', '/foo']);

        // Select /foo then expand /foo.
        getInputElement('/foo').click();
        expandPath('/foo');

        // After selecting and expanding /foo the /foo/bar path should be
        // expanded.
        await waitAndAssertVisiblePaths(['/', '/foo', '/foo/bar']);

        // The path should also already be selected and disabled from selecting
        // as its parent element is already selected.
        assertTrue(getInputElement('/foo/bar').checked);
        assertTrue(getInputElement('/foo/bar').disabled);

        // Only the exact selected paths should be returned, not the descendants
        // (despite being visually shown as checked).
        assertArrayEquals(getFolderSelector().selectedPaths, ['/foo']);
      });
});