chromium/ui/file_manager/file_manager/widgets/xf_conflict_dialog_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 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';

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

import {waitUntil} from '../common/js/test_error_reporting.js';

import type {XfConflictDialog} from './xf_conflict_dialog.js';
import {ConflictResolveType} from './xf_conflict_dialog.js';

/**
 * Creates new <xf-conflict-dialog> for each test.
 */
export function setUp() {
  document.body.innerHTML = getTrustedHTML`
    <xf-conflict-dialog></xf-conflict-dialog>
  `;
}

/**
 * Returns the <xf-conflict-dialog> element.
 */
function getConflictDialogElement(): XfConflictDialog {
  const element = document.querySelector('xf-conflict-dialog');
  assertNotEquals(null, element);
  assertEquals('XF-CONFLICT-DIALOG', element!.tagName);
  return element! as XfConflictDialog;
}

/*
 * Tests that the message element has the focus when the dialog opens. Child
 * UI elements (the dialog buttons, checkbox) must not be focused by design,
 * and ARIA will announce the focused element (the message).
 */
export async function testDialogShowFocus(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  const randomTrueFalse = () => {  // Returns random Boolean.
    return Math.random() < 0.5;
  };

  // Open the conflict dialog for 'file-name' with random optional params.
  element.show('file-name', randomTrueFalse(), randomTrueFalse());
  await waitUntil(() => dialog.open);

  // Check: the dialog should be visible.
  assertNotEquals('none', window.getComputedStyle(dialog).display);
  assertFalse(dialog.hidden);

  // Check: the message element should have the focus.
  const message = element.getMessageElement();
  await waitUntil(() => element.shadowRoot!.activeElement === message);

  // Check: the child <dialog> title element should be removed.
  const child = element.getHtmlDialogElement();
  assertEquals(null, child.querySelector('#title'));

  // Check: the child <dialog> has the correct ARIA attributes.
  assertEquals('true', child.getAttribute('aria-modal'));
  assertFalse(child.hasAttribute('aria-describedby'));
  assertFalse(child.hasAttribute('aria-labelledby'));
  assertFalse(child.hasAttribute('aria-label'));

  done();
}

/*
 * Tests that the dialog opens with no 'Apply to all' checkbox shown.
 */
export async function testDialogShow(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name.
  element.show('file.txt');
  await waitUntil(() => dialog.open);

  // Check: the dialog message should contain 'file.txt'.
  const message = element.getMessageElement();
  assertNotEquals('none', window.getComputedStyle(message).display);
  assertTrue(message.innerText.includes('A file named "file.txt"'));
  assertFalse(message.hidden);

  // Check: the 'Apply to all' checkbox should not be shown.
  const checkbox = element.getCheckboxElement();
  assertEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.checked);
  assertTrue(checkbox.hidden);

  done();
}

/*
 * Tests that the dialog can open with the 'Apply to all' checkbox shown.
 */
export async function testDialogShowCheckbox(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name, with a checkbox.
  const withCheckbox = true;
  element.show('image.jpg', withCheckbox);
  await waitUntil(() => dialog.open);

  // Check: the dialog message should contain 'image.jpg'.
  const message = element.getMessageElement();
  assertNotEquals('none', window.getComputedStyle(message).display);
  assertTrue(message.innerText.includes('A file named "image.jpg"'));
  assertFalse(message.hidden);

  // Check: the 'Apply to all' checkbox should be shown.
  const checkbox = element.getCheckboxElement();
  assertNotEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.hasAttribute('disabled'));
  assertFalse(checkbox.checked);
  assertFalse(checkbox.hidden);

  done();
}

/*
 * Tests that the dialog can open with the directory (aka a folder) message
 * text shown.
 */
export async function testDialogShowDirectoryMessageText(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name, with no checkbox, and
  // (test-case) message text indicating that the file type is a folder.
  const isDirectory = true;
  const withCheckbox = false;
  element.show('Downloads', withCheckbox, isDirectory);
  await waitUntil(() => dialog.open);

  // Check: the dialog message should contain 'Downloads'.
  const message = element.getMessageElement();
  assertNotEquals('none', window.getComputedStyle(message).display);
  assertTrue(message.innerText.includes('A folder named "Downloads"'));
  assertFalse(message.hidden);

  // Check: the 'Apply to all' checkbox should not be shown.
  const checkbox = element.getCheckboxElement();
  assertEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.checked);
  assertTrue(checkbox.hidden);

  done();
}

/*
 * Tests that clicking the 'Apply to all' checkbox changes the dialog checked
 * state attributes, properties, and button texts.
 */
export async function testDialogCheckboxChangesCheckedState(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name, with a checkbox.
  const withCheckbox = true;
  element.show('image.jpg', withCheckbox);
  await waitUntil(() => dialog.open);

  // Check: the 'Apply to all' checkbox should be shown.
  const checkbox = element.getCheckboxElement();
  assertNotEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('disabled'));
  assertFalse(checkbox.checked);
  assertFalse(checkbox.hidden);

  // Get the keepboth and replace buttons.
  const keepboth = element.getKeepbothButton();
  const replace = element.getReplaceButton();
  assertFalse(element.hasAttribute('checked'));
  assertEquals('Keep both', keepboth.innerText);
  assertEquals('Replace', replace.innerText);

  // Check: clicking the checkbox should change the checked state.
  checkbox.click();
  await waitUntil(() => element.hasAttribute('checked'));
  assertTrue(checkbox.hasAttribute('checked'));
  assertTrue(checkbox.checked);
  assertEquals('Keep all', keepboth.innerText);
  assertEquals('Replace all', replace.innerText);

  // Check: the checkbox should gain the focus.
  await waitUntil(() => element.shadowRoot!.activeElement === checkbox);

  // Check: clicking the checkbox should change the checked state.
  checkbox.click();
  await waitUntil(() => !element.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.checked);
  assertEquals('Keep both', keepboth.innerText);
  assertEquals('Replace', replace.innerText);

  // Check: the checkbox should retain the focus.
  await waitUntil(() => element.shadowRoot!.activeElement === checkbox);

  done();
}

/*
 * Tests that the dialog checked state is reset when the dialog opens.
 */
export async function testDialogShowResetsCheckedState(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name, with a checkbox.
  const withCheckbox = true;
  const resultPromise = element.show('image.jpg', withCheckbox);
  await waitUntil(() => dialog.open);

  // Get the keepboth and replace buttons.
  const keepboth = element.getKeepbothButton();
  const replace = element.getReplaceButton();
  assertFalse(element.hasAttribute('checked'));
  assertEquals('Keep both', keepboth.innerText);
  assertEquals('Replace', replace.innerText);

  // Check: the 'Apply to all' checkbox should be shown.
  const checkbox = element.getCheckboxElement();
  assertNotEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('disabled'));
  assertFalse(checkbox.checked);
  assertFalse(checkbox.hidden);

  // Check: clicking the checkbox should change the checked state.
  checkbox.click();
  await waitUntil(() => element.hasAttribute('checked'));
  assertTrue(checkbox.hasAttribute('checked'));
  assertTrue(checkbox.checked);
  assertEquals('Keep all', keepboth.innerText);
  assertEquals('Replace all', replace.innerText);

  // Close the modal dialog while in checked state. Closing should reject
  // the resultPromise with a cancelled Error.
  try {
    dialog.close();
    await resultPromise;
    assertNotReached();
  } catch (error: any) {
    assertEquals('Error: dialog cancelled', error?.toString());
    await waitUntil(() => !dialog.open);
  }

  // Check: the dialog closed and should still have checked state.
  assertFalse(dialog.open);
  assertTrue(element.hasAttribute('checked'));
  assertTrue(checkbox.hasAttribute('checked'));
  assertTrue(checkbox.checked);
  assertEquals('Keep all', keepboth.innerText);
  assertEquals('Replace all', replace.innerText);

  // Open the dialog again.
  element.show('file.txt', withCheckbox);
  await waitUntil(() => dialog.open);

  // Check: the checked state should be reset when the dialog opens.
  await waitUntil(() => !element.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.checked);
  assertEquals('Keep both', keepboth.innerText);
  assertEquals('Replace', replace.innerText);

  done();
}

/*
 * Tests that the dialog consumes keyboard events.
 */
export async function testDialogConsumesKeyboardEvents(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name.
  element.show('file.txt');
  await waitUntil(() => dialog.open);

  // Keyboard events should not bubble up to the <xf-conflict-dialog>. The
  // <cr-dialog> should consume them (modal behavior).
  assertTrue(dialog.consumeKeydownEvent);
  let consumedKeyEvent = true;
  element.addEventListener('keydown', () => {
    console.error('FAILED: <xf-conflict-dialog> keydown event');
    consumedKeyEvent = false;
  });

  // Create a <ctrl>-A keyboard event. The event has 'composed' true so it
  // can cross shadow DOM boundaries and bubble up into the DOM.
  const keyboardEvent = new KeyboardEvent('keydown', {
    bubbles: true,
    cancelable: true,
    composed: true,
    ctrlKey: true,
    key: 'A',
  });

  // Add <cr-dialog> listener to confirm it saw the keyboard event.
  let keydownSeen = false;
  dialog.addEventListener('keydown', () => {
    keydownSeen = true;
  });

  // Check: the <cr-dialog> should consume keyboard events.
  assertTrue(dialog.dispatchEvent(keyboardEvent));
  await waitUntil(() => keydownSeen);
  assertTrue(consumedKeyEvent);

  done();
}

/*
 * Tests that cancel button closes the dialog with a cancelled error.
 */
export async function testDialogCancelButton(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name.
  const resultPromise = element.show('file.txt');
  await waitUntil(() => dialog.open);

  // Check: the cancel button should be shown.
  const cancel = element.getCancelButton();
  assertNotEquals('none', window.getComputedStyle(cancel).display);
  assertFalse(cancel.hasAttribute('disabled'));
  assertFalse(cancel.hidden);

  // Clicking the cancel button should close the dialog.
  cancel.click();
  await waitUntil(() => !dialog.open);

  // And reject resultPromise with a dialog cancelled Error.
  try {
    await resultPromise;
    assertNotReached();
  } catch (error: any) {
    assertEquals('Error: dialog cancelled', error?.toString());
  }

  done();
}

/*
 * Tests that replace button closes the dialog with a 'replace' result.
 */
export async function testDialogReplaceButton(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name.
  const resultPromise = element.show('file1.txt');
  await waitUntil(() => dialog.open);

  // Check: the replace button should be shown.
  const replace = element.getReplaceButton();
  assertNotEquals('none', window.getComputedStyle(replace).display);
  assertFalse(replace.hasAttribute('disabled'));
  assertFalse(replace.hidden);

  // Clicking the replace button should close the dialog.
  replace.click();
  await waitUntil(() => !dialog.open);

  // And resolve the resultPromise with a 'replace' result.
  try {
    const result = await resultPromise;
    assertEquals(ConflictResolveType.REPLACE, result.resolve);
    assertEquals(false, result.checked);
  } catch (error) {
    console.error('FAILED: <xf-conflict-dialog>', error);
    assertNotReached();
  }

  // Open the conflict dialog for a given file name, with the checkbox.
  const resultCheckboxPromise = element.show('file2.txt', true);
  await waitUntil(() => dialog.open);
  assertFalse(element.hasAttribute('checked'));
  assertEquals('Replace', replace.innerText);

  // Check: the 'Apply to all' checkbox should be shown.
  const checkbox = element.getCheckboxElement();
  assertNotEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('disabled'));
  assertFalse(checkbox.checked);
  assertFalse(checkbox.hidden);

  // Check: clicking the checkbox should change the checked state.
  checkbox.click();
  await waitUntil(() => element.hasAttribute('checked'));
  assertTrue(checkbox.hasAttribute('checked'));
  assertTrue(checkbox.checked);
  assertEquals('Replace all', replace.innerText);

  // Check: the replace button should be shown.
  assertNotEquals('none', window.getComputedStyle(replace).display);
  assertFalse(replace.hasAttribute('disabled'));
  assertFalse(replace.hidden);

  // Clicking the replace button should close the dialog.
  replace.click();
  await waitUntil(() => !dialog.open);

  // And resolve the resultCheckboxPromise with a 'replace' result.
  try {
    const result = await resultCheckboxPromise;
    assertEquals(ConflictResolveType.REPLACE, result.resolve);
    assertEquals(true, result.checked);
  } catch (error) {
    console.error('FAILED: <xf-conflict-dialog>', error);
    assertNotReached();
  }

  done();
}

/*
 * Tests that keepboth button closes the dialog with a 'keepboth' result.
 */
export async function testDialogKeepbothButton(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Open the conflict dialog for a given file name.
  const resultPromise = element.show('file1.txt');
  await waitUntil(() => dialog.open);

  // Check: the keepboth button should be shown.
  const keepboth = element.getKeepbothButton();
  assertNotEquals('none', window.getComputedStyle(keepboth).display);
  assertFalse(keepboth.hasAttribute('disabled'));
  assertFalse(keepboth.hidden);

  // Clicking the keepboth button should close the dialog.
  keepboth.click();
  await waitUntil(() => !dialog.open);

  // And resolve the resultPromise with a 'keepboth' result.
  try {
    const result = await resultPromise;
    assertEquals(ConflictResolveType.KEEPBOTH, result.resolve);
    assertEquals(false, result.checked);
  } catch (error) {
    console.error('FAILED: <xf-conflict-dialog>', error);
    assertNotReached();
  }

  // Open the conflict dialog for a given file name, with the checkbox.
  const resultCheckboxPromise = element.show('file2.txt', true);
  await waitUntil(() => dialog.open);
  assertFalse(element.hasAttribute('checked'));
  assertEquals('Keep both', keepboth.innerText);

  // Check: the 'Apply to all' checkbox should be shown.
  const checkbox = element.getCheckboxElement();
  assertNotEquals('none', window.getComputedStyle(checkbox).display);
  assertFalse(checkbox.hasAttribute('checked'));
  assertFalse(checkbox.hasAttribute('disabled'));
  assertFalse(checkbox.checked);
  assertFalse(checkbox.hidden);

  // Check: clicking the checkbox should change the checked state.
  checkbox.click();
  await waitUntil(() => element.hasAttribute('checked'));
  assertTrue(checkbox.hasAttribute('checked'));
  assertTrue(checkbox.checked);
  assertEquals('Keep all', keepboth.innerText);

  // Check: the keepboth button should be shown.
  assertNotEquals('none', window.getComputedStyle(keepboth).display);
  assertFalse(keepboth.hasAttribute('disabled'));
  assertFalse(keepboth.hidden);

  // Clicking the keepboth button should close the dialog.
  keepboth.click();
  await waitUntil(() => !dialog.open);

  // And resolve the resultCheckboxPromise with a 'keepboth' result.
  try {
    const result = await resultCheckboxPromise;
    assertEquals(ConflictResolveType.KEEPBOTH, result.resolve);
    assertEquals(true, result.checked);
  } catch (error) {
    console.error('FAILED <xf-conflict-dialog>', error);
    assertNotReached();
  }

  done();
}

/**
 * Tests that requests to open the dialog are serialized.
 */
export async function testDialogShowRequestsSerialize(done: () => void) {
  const element = getConflictDialogElement();

  // Check: the dialog should not be open.
  const dialog = element.getDialogElement();
  assertFalse(dialog.open);

  // Send multiple show requests to the <xf-conflict-dialog>.
  const resultPromise1 = element.show('file1.txt');
  const resultPromise2 = element.show('file2.txt');

  // Wait until the dialog is open.
  await waitUntil(() => dialog.open);

  // Check: the dialog message should be for 'file1.txt'.
  const message = element.getMessageElement();
  await waitUntil(() => message.innerText.includes('file1.txt'));

  // Click the keepboth button.
  const keepboth = element.getKeepbothButton();
  keepboth.click();

  // Check: resultPromise1 should resolve with a 'keepboth' result.
  try {
    const result = await resultPromise1;
    assertEquals(ConflictResolveType.KEEPBOTH, result.resolve);
    assertEquals(false, result.checked);
  } catch (error) {
    console.error('FAILED: <xf-conflict-dialog>', error);
    assertNotReached();
  }

  // Wait until the dialog is re-opened for 'file2.txt'.
  await waitUntil(() => dialog.open);
  await waitUntil(() => message.innerText.includes('file2.txt'));

  // Click the replace button.
  const replace = element.getReplaceButton();
  replace.click();

  // Check: resultPromise2 should resolve with a 'replace' result.
  try {
    const result = await resultPromise2;
    assertEquals(ConflictResolveType.REPLACE, result.resolve);
    assertEquals(false, result.checked);
  } catch (error) {
    console.error('FAILED: <xf-conflict-dialog>', error);
    assertNotReached();
  }

  done();
}