chromium/chrome/test/data/webui/settings/search_engines_page_test.ts

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

// clang-format off
import 'chrome://settings/lazy_load.js';

import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {CrInputElement, SettingsSearchEngineEditDialogElement, SettingsSearchEnginesListElement, SettingsSearchEnginesPageElement} from 'chrome://settings/lazy_load.js';
import type {SearchEnginesInfo} from 'chrome://settings/settings.js';
import {SearchEnginesBrowserProxyImpl, SearchEnginesInteractions} from 'chrome://settings/settings.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

import {createSampleOmniboxExtension, createSampleSearchEngine, TestSearchEnginesBrowserProxy} from './test_search_engines_browser_proxy.js';
// clang-format on

suite('AddSearchEngineDialogTests', function() {
  let dialog: SettingsSearchEngineEditDialogElement;
  let browserProxy: TestSearchEnginesBrowserProxy;

  setup(function() {
    browserProxy = new TestSearchEnginesBrowserProxy();
    SearchEnginesBrowserProxyImpl.setInstance(browserProxy);
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    dialog = document.createElement('settings-search-engine-edit-dialog');
    document.body.appendChild(dialog);
  });

  teardown(function() {
    dialog.remove();
  });

  // Tests that the dialog calls 'searchEngineEditStarted' and
  // 'searchEngineEditCancelled' when closed from the 'cancel' button.
  test('DialogOpenAndCancel', async function() {
    await browserProxy.whenCalled('searchEngineEditStarted');
    dialog.$.cancel.click();
    await browserProxy.whenCalled('searchEngineEditCancelled');
  });

  // Tests the dialog to add a new search engine. Specifically
  //  - cr-input elements are empty initially.
  //  - action button initially disabled.
  //  - validation is triggered on 'input' event.
  //  - action button is enabled when all fields are valid.
  //  - action button triggers appropriate browser signal when tapped.
  test('DialogAddSearchEngine', async function() {
    /**
     * Triggers an 'input' event on the cr-input element and checks that
     * validation is triggered.
     */
    function inputAndValidate(inputId: string): Promise<void> {
      const inputElement =
          dialog.shadowRoot!.querySelector<CrInputElement>(`#${inputId}`)!;
      browserProxy.resetResolver('validateSearchEngineInput');
      inputElement.dispatchEvent(
          new CustomEvent('input', {bubbles: true, composed: true}));
      return inputElement.value !== '' ?
          // Expecting validation only on non-empty values.
          browserProxy.whenCalled('validateSearchEngineInput') :
          Promise.resolve();
    }

    const actionButton = dialog.$.actionButton;

    await browserProxy.whenCalled('searchEngineEditStarted');
    assertEquals('', dialog.$.searchEngine.value);
    assertEquals('', dialog.$.keyword.value);
    assertEquals('', dialog.$.queryUrl.value);
    assertFalse(dialog.$.queryUrl.readonly);
    assertFalse(dialog.$.cancel.disabled);
    assertTrue(actionButton.disabled);
    assertEquals(
        actionButton.textContent!.trim(), loadTimeData.getString('add'));
    await inputAndValidate('searchEngine');
    await inputAndValidate('keyword');
    await inputAndValidate('queryUrl');

    // Manually set the text to a non-empty string for all fields.
    dialog.$.searchEngine.value = 'foo';
    dialog.$.keyword.value = 'bar';
    dialog.$.queryUrl.value = 'baz';

    await inputAndValidate('searchEngine');
    // Assert that the action button has been enabled now that all
    // input is valid and non-empty.
    assertFalse(actionButton.disabled);
    actionButton.click();
    await browserProxy.whenCalled('searchEngineEditCompleted');
  });

  test('DialogCloseWhenEnginesChangedModelEngineNotFound', async function() {
    dialog.set('model', createSampleSearchEngine({id: 0, name: 'G'}));
    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [],
      others: [createSampleSearchEngine({id: 1, name: 'H'})],
      extensions: [],
    });
    await browserProxy.whenCalled('searchEngineEditCancelled');
  });

  test('DialogValidateInputsWhenEnginesChanged', async function() {
    dialog.set('model', createSampleSearchEngine({name: 'G'}));
    dialog.set('keyword_', 'G');
    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [],
      others: [createSampleSearchEngine({name: 'G'})],
      extensions: [],
    });
    await browserProxy.whenCalled('validateSearchEngineInput');
  });
});

suite('SearchEnginePageTests', function() {
  let page: SettingsSearchEnginesPageElement;
  let searchEnginesLists: NodeListOf<SettingsSearchEnginesListElement>;
  let browserProxy: TestSearchEnginesBrowserProxy;

  const searchEnginesInfo: SearchEnginesInfo = {
    defaults: [
      createSampleSearchEngine({
        id: 0,
        name: 'search_engine_default_A',
        displayName: 'A displayName',
        keyword: 'default A',
      }),
      createSampleSearchEngine({
        id: 1,
        name: 'search_engine_default_B',
        displayName: 'B displayName',
        isManaged: true,
        keyword: 'default B',
        url: 'https://www.default_b.com/search?q=%s',
      }),
      createSampleSearchEngine({
        id: 2,
        name: 'search_engine_default_C',
        displayName: 'C displayName',
        keyword: 'default C',
        url: 'https://www.default_c.com/search?q=%s',
        urlLocked: true,
      }),
      createSampleSearchEngine({
        id: 3,
        name: 'search_engine_default_D',
        displayName: 'D displayName',
        keyword: 'default D',
      }),
      createSampleSearchEngine({
        id: 4,
        name: 'search_engine_default_E',
        displayName: 'E displayName',
        keyword: 'default E',
      }),
      createSampleSearchEngine({
        id: 5,
        name: 'search_engine_default_F',
        displayName: 'F displayName',
        keyword: 'default F',
      }),
    ],
    actives: [createSampleSearchEngine({id: 6})],
    others: [
      createSampleSearchEngine({
        id: 7,
        name: 'search_engine_G',
        displayName: 'search_engine_G displayName',
      }),
      createSampleSearchEngine(
          {id: 8, name: 'search_engine_F', keyword: 'search_engine_F keyword'}),
      createSampleSearchEngine({id: 9, name: 'search_engine_E'}),
      createSampleSearchEngine({id: 10, name: 'search_engine_D'}),
      createSampleSearchEngine({id: 11, name: 'search_engine_C'}),
      createSampleSearchEngine({id: 12, name: 'search_engine_B'}),
      createSampleSearchEngine({id: 13, name: 'search_engine_A'}),
    ],
    extensions: [createSampleOmniboxExtension()],
  };

  setup(async function() {
    browserProxy = new TestSearchEnginesBrowserProxy();

    // Purposefully pass a clone of |searchEnginesInfo| to avoid any
    // mutations on ground truth data.
    browserProxy.setSearchEnginesInfo({
      defaults: searchEnginesInfo.defaults.slice(),
      actives: searchEnginesInfo.actives.slice(),
      others: searchEnginesInfo.others.slice(),
      extensions: searchEnginesInfo.extensions.slice(),
    });
    SearchEnginesBrowserProxyImpl.setInstance(browserProxy);
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    page = document.createElement('settings-search-engines-page');
    page.set('prefs.omnibox.keyword_space_triggering_enabled', {
      key: 'prefs.omnibox.keyword_space_triggering_enabled',
      type: chrome.settingsPrivate.PrefType.BOOLEAN,
      value: true,
    });
    document.body.appendChild(page);
    await browserProxy.whenCalled('getSearchEnginesList');
  });

  teardown(function() {
    page.remove();
  });

  // Tests that the page is querying and displaying search engine info on
  // startup.
  test('Initialization', function() {
    searchEnginesLists =
        page.shadowRoot!.querySelectorAll('settings-search-engines-list');
    assertEquals(3, searchEnginesLists.length);

    flush();
  });

  test('testDefaultsList', function() {
    const defaultsListElement = searchEnginesLists[0]!;

    // The defaults list should only show the name and shortcut columns.
    assertFalse(
        defaultsListElement.shadowRoot!.querySelector('.name')!.hasAttribute(
            'hidden'));
    assertFalse(defaultsListElement.shadowRoot!.querySelector('.shortcut')!
                    .hasAttribute('hidden'));
    assertTrue(
        defaultsListElement.shadowRoot!.querySelector('.url')!.hasAttribute(
            'hidden'));

    // The default engines list should not collapse and should show all entries
    // in the list by default.
    const lists =
        defaultsListElement.shadowRoot!.querySelectorAll('dom-repeat');
    assertEquals(1, lists.length);
    const defaultsEntries = lists[0]!.items;
    assertEquals(searchEnginesInfo.defaults.length, defaultsEntries!.length);
  });

  test('testActivesList', function() {
    const activesListElement = searchEnginesLists[1]!;

    // The actives list should only show the name and shortcut columns.
    assertFalse(
        activesListElement.shadowRoot!.querySelector('.name')!.hasAttribute(
            'hidden'));
    assertFalse(
        activesListElement.shadowRoot!.querySelector('.shortcut')!.hasAttribute(
            'hidden'));
    assertTrue(
        activesListElement.shadowRoot!.querySelector('.url')!.hasAttribute(
            'hidden'));

    // With less than `visibleEnginesSize` elements in the list, all elements
    // should be visible and the collapsible section should not be present.
    const lists = activesListElement.shadowRoot!.querySelectorAll('dom-repeat');
    const visibleEntries = lists[0]!.items;
    const collapsedEntries = lists[1]!.items;
    assertEquals(searchEnginesInfo.actives.length, visibleEntries!.length);
    assertEquals(0, collapsedEntries!.length);

    const expandButton =
        activesListElement.shadowRoot!.querySelector('cr-expand-button');
    assertTrue(!!expandButton);
    assertTrue(expandButton!.hasAttribute('hidden'));
  });

  test('testOthersList', function() {
    const othersListElement = searchEnginesLists[2]!;

    // The others list should only show the name and url columns.
    assertFalse(
        othersListElement.shadowRoot!.querySelector('.name')!.hasAttribute(
            'hidden'));
    assertTrue(
        othersListElement.shadowRoot!.querySelector('.shortcut')!.hasAttribute(
            'hidden'));
    assertFalse(
        othersListElement.shadowRoot!.querySelector('.url')!.hasAttribute(
            'hidden'));

    // Any engines greater than `visibleEnginesSize` will be in a second list
    // under the collapsible section. The button to expand this section must be
    // visible.
    const visibleEnginesSize = othersListElement.visibleEnginesSize;
    const lists = othersListElement.shadowRoot!.querySelectorAll('dom-repeat');
    const visibleEntries = lists[0]!.items;
    const collapsedEntries = lists[1]!.items;
    assertEquals(visibleEnginesSize, visibleEntries!.length);
    assertEquals(
        searchEnginesInfo.others.length - visibleEnginesSize,
        collapsedEntries!.length);

    // Ensure that the search engines have reverse alphabetical order in the
    // model.
    for (let i = 0; i < searchEnginesInfo.others.length - 1; i++) {
      assertTrue(
          searchEnginesInfo.others[i]!.name >=
          searchEnginesInfo.others[i + 1]!.name);
    }

    const othersEntries = othersListElement!.shadowRoot!.querySelectorAll(
        'settings-search-engine-entry');

    // Ensure that they are displayed in alphabetical order.
    for (let i = 0; i < othersEntries!.length - 1; i++) {
      assertTrue(
          othersEntries[i]!.engine.name <= othersEntries[i + 1]!.engine.name);
    }

    const expandButton =
        othersListElement.shadowRoot!.querySelector('cr-expand-button');
    assertTrue(!!expandButton);
    assertFalse(expandButton!.hasAttribute('hidden'));
  });

  // Test that the keyboard shortcut radio buttons are shown as expected, and
  // toggling them fires the appropriate events.
  test('KeyboardShortcutSettingToggle', async function() {
    const radioGroup = page.$.keyboardShortcutSettingGroup;
    assertTrue(!!radioGroup);
    assertFalse(radioGroup.hidden);

    const radioButtons =
        page.shadowRoot!.querySelectorAll('controlled-radio-button');
    assertEquals(2, radioButtons.length);
    assertEquals('true', radioButtons.item(0)!.name);
    assertEquals('false', radioButtons.item(1)!.name);

    // Check behavior when switching space triggering off.
    radioButtons.item(1)!.click();
    await eventToPromise('selected-changed', radioGroup);
    assertEquals('false', radioGroup.selected);
    let result =
        await browserProxy.whenCalled('recordSearchEnginesPageHistogram');
    assertEquals(SearchEnginesInteractions.KEYBOARD_SHORTCUT_TAB, result);
    browserProxy.reset();

    // Check behavior when switching space triggering on.
    radioButtons.item(0).click();
    await eventToPromise('selected-changed', radioGroup);
    assertEquals('true', radioGroup.selected);
    result = await browserProxy.whenCalled('recordSearchEnginesPageHistogram');
    assertEquals(
        SearchEnginesInteractions.KEYBOARD_SHORTCUT_SPACE_OR_TAB, result);
  });

  // Test that the "no other search engines" message is shown/hidden as
  // expected.
  test('NoOtherSearchEnginesMessage', function() {
    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [],
      others: [],
      extensions: [],
    });

    const message = page.shadowRoot!.querySelector('#noOtherEngines');
    assertTrue(!!message);
    assertFalse(message!.hasAttribute('hidden'));

    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [],
      others: [createSampleSearchEngine()],
      extensions: [],
    });
    assertTrue(message!.hasAttribute('hidden'));
  });

  // Tests that the add search engine dialog opens when the corresponding
  // button is tapped.
  test('AddSearchEngineDialog', function() {
    assertFalse(
        !!page.shadowRoot!.querySelector('settings-search-engine-edit-dialog'));
    const addSearchEngineButton =
        page.shadowRoot!.querySelector<HTMLButtonElement>('#addSearchEngine')!;
    assertTrue(!!addSearchEngineButton);

    addSearchEngineButton.click();
    flush();
    assertTrue(
        !!page.shadowRoot!.querySelector('settings-search-engine-edit-dialog'));
  });

  test('EditSearchEngineDialog', async function() {
    const engine = searchEnginesInfo.others[0]!;
    page.dispatchEvent(new CustomEvent('view-or-edit-search-engine', {
      bubbles: true,
      composed: true,
      detail: {
        engine,
        anchorElement: page.shadowRoot!.querySelector('#addSearchEngine')!,
      },
    }));
    const modelIndex = await browserProxy.whenCalled('searchEngineEditStarted');
    assertEquals(engine.modelIndex, modelIndex);
    const dialog =
        page.shadowRoot!.querySelector('settings-search-engine-edit-dialog')!;
    assertTrue(!!dialog);

    // Check that the cr-input fields are pre-populated.
    assertEquals(engine.name, dialog.$.searchEngine.value);
    assertEquals(engine.keyword, dialog.$.keyword.value);
    assertEquals(engine.url, dialog.$.queryUrl.value);

    assertFalse(dialog.$.cancel.hidden);
    assertFalse(dialog.$.cancel.disabled);
    assertFalse(dialog.$.actionButton.hidden);
    assertFalse(dialog.$.actionButton.disabled);
    assertEquals(
        dialog.$.actionButton.textContent!.trim(),
        loadTimeData.getString('save'));
  });

  test('EditSearchEngineDialog_IsManaged', async function() {
    const engine = searchEnginesInfo.defaults[1]!;
    page.dispatchEvent(new CustomEvent('view-or-edit-search-engine', {
      bubbles: true,
      composed: true,
      detail: {
        engine,
        anchorElement: page.shadowRoot!.querySelector('#addSearchEngine')!,
      },
    }));
    const modelIndex = await browserProxy.whenCalled('searchEngineEditStarted');
    assertEquals(engine.modelIndex, modelIndex);
    const dialog =
        page.shadowRoot!.querySelector('settings-search-engine-edit-dialog')!;
    assertTrue(!!dialog);

    // Check that the cr-input fields are pre-populated.
    assertEquals(engine.name, dialog.$.searchEngine.value);
    assertTrue(dialog.$.searchEngine.readonly);
    assertEquals(engine.keyword, dialog.$.keyword.value);
    assertTrue(dialog.$.keyword.readonly);
    assertEquals(engine.url, dialog.$.queryUrl.value);
    assertTrue(dialog.$.queryUrl.readonly);

    assertTrue(dialog.$.cancel.hidden);
    assertFalse(dialog.$.actionButton.hidden);
    assertFalse(dialog.$.actionButton.disabled);
    assertEquals(
        dialog.$.actionButton.textContent!.trim(),
        loadTimeData.getString('done'));

    // Ensures that field validation is not run for search engines created by
    // policy (b/348165485).
    browserProxy.resetResolver('validateSearchEngineInput');
    dialog.$.keyword.dispatchEvent(
        new CustomEvent('input', {bubbles: true, composed: true}));
    assertEquals(0, browserProxy.getCallCount('validateSearchEngineInput'));

    assertTrue(dialog.$.cancel.hidden);
    assertFalse(dialog.$.actionButton.hidden);
    assertFalse(dialog.$.actionButton.disabled);
  });

  test('EditSearchEngineDialog_UrlLocked', async function() {
    const engine = searchEnginesInfo.defaults[2]!;
    page.dispatchEvent(new CustomEvent('view-or-edit-search-engine', {
      bubbles: true,
      composed: true,
      detail: {
        engine,
        anchorElement: page.shadowRoot!.querySelector('#addSearchEngine')!,
      },
    }));
    const modelIndex = await browserProxy.whenCalled('searchEngineEditStarted');
    assertEquals(engine.modelIndex, modelIndex);
    const dialog =
        page.shadowRoot!.querySelector('settings-search-engine-edit-dialog')!;
    assertTrue(!!dialog);

    // Check that the cr-input fields are pre-populated.
    assertEquals(engine.name, dialog.$.searchEngine.value);
    assertEquals(engine.keyword, dialog.$.keyword.value);
    assertEquals(engine.url, dialog.$.queryUrl.value);
    assertTrue(dialog.$.queryUrl.readonly);

    assertFalse(dialog.$.cancel.hidden);
    assertFalse(dialog.$.cancel.disabled);
    assertFalse(dialog.$.actionButton.hidden);
    assertFalse(dialog.$.actionButton.disabled);
    assertEquals(
        dialog.$.actionButton.textContent!.trim(),
        loadTimeData.getString('save'));
  });

  // Tests that filtering the three search engines lists works, and that the
  // "no search results" message is shown as expected.
  test('FilterSearchEngines', function() {
    flush();

    // TODO: Lookup via array index  may not be the best approach, because
    // changing the order or number of settings-search-engines-list elements
    // can break this test. Maybe we can add an id to every relevant element and
    // use that for lookup.
    function getListItems(listIndex: number) {
      const list = listIndex === 3 /* extensions */ ?
          page.shadowRoot!.querySelector('iron-list')!.items :
          page.shadowRoot!
              .querySelectorAll('settings-search-engines-list')[listIndex]!
              .shadowRoot!.querySelectorAll('settings-search-engine-entry');

      return list;
    }

    function assertSearchResults(
        defaultsCount: number, othersCount: number, extensionsCount: number) {
      assertEquals(defaultsCount, getListItems(0)!.length);
      assertEquals(othersCount, getListItems(2)!.length);
      assertEquals(extensionsCount, getListItems(3)!.length);

      const noResultsElements = Array.from(
          page.shadowRoot!.querySelectorAll<HTMLElement>('.no-search-results'));
      assertEquals(defaultsCount > 0, noResultsElements[0]!.hidden);
      assertEquals(othersCount > 0, noResultsElements[2]!.hidden);
      assertEquals(extensionsCount > 0, noResultsElements[3]!.hidden);
    }

    assertSearchResults(6, 7, 1);

    // Search by name
    page.filter = searchEnginesInfo.defaults[0]!.name;
    flush();
    assertSearchResults(1, 0, 0);

    // Search by displayName
    page.filter = searchEnginesInfo.others[0]!.displayName;
    flush();
    assertSearchResults(0, 1, 0);

    // Search by keyword
    page.filter = searchEnginesInfo.others[1]!.keyword;
    flush();
    assertSearchResults(0, 1, 0);

    // Search by URL
    page.filter = 'search?';
    flush();
    assertSearchResults(6, 7, 0);

    // Test case where none of the sublists have results.
    page.filter = 'does not exist';
    flush();
    assertSearchResults(0, 0, 0);

    // Test case where an 'extension' search engine matches.
    page.filter = 'extension';
    flush();
    assertSearchResults(0, 0, 1);
  });

  // Test that the "no other search engines" message is shown/hidden as
  // expected.
  test('NoSearchEnginesMessages', function() {
    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [],
      others: [],
      extensions: [],
    });

    const messageActive = page.shadowRoot!.querySelector('#noActiveEngines');
    assertTrue(!!messageActive);
    assertFalse(messageActive!.hasAttribute('hidden'));

    const messageOther = page.shadowRoot!.querySelector('#noOtherEngines');
    assertTrue(!!messageOther);
    assertFalse(messageOther!.hasAttribute('hidden'));

    webUIListenerCallback('search-engines-changed', {
      defaults: [],
      actives: [createSampleSearchEngine()],
      others: [createSampleSearchEngine()],
      extensions: [],
    });
    assertTrue(messageActive!.hasAttribute('hidden'));
    assertTrue(messageOther!.hasAttribute('hidden'));
  });
});