chromium/chrome/test/data/webui/history/history_app_test.ts

// Copyright 2024 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://history/history.js';

import type {HistoryAppElement} from 'chrome://history/history.js';
import {BrowserServiceImpl, CrRouter, HistoryEmbeddingsBrowserProxyImpl, HistoryEmbeddingsPageHandlerRemote} from 'chrome://history/history.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {isMac} from 'chrome://resources/js/platform.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {pressAndReleaseKeyOn} from 'chrome://webui-test/keyboard_mock_interactions.js';
import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
import {TestMock} from 'chrome://webui-test/test_mock.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

import {TestBrowserService} from './test_browser_service.js';

suite('HistoryAppTest', function() {
  let element: HistoryAppElement;
  let browserService: TestBrowserService;
  let embeddingsHandler: TestMock<HistoryEmbeddingsPageHandlerRemote>&
      HistoryEmbeddingsPageHandlerRemote;

  setup(() => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;

    loadTimeData.overrideValues({
      historyEmbeddingsSearchMinimumWordCount: 2,
      enableHistoryEmbeddings: true,
    });

    browserService = new TestBrowserService();
    BrowserServiceImpl.setInstance(browserService);
    embeddingsHandler = TestMock.fromClass(HistoryEmbeddingsPageHandlerRemote);
    HistoryEmbeddingsBrowserProxyImpl.setInstance(
        new HistoryEmbeddingsBrowserProxyImpl(embeddingsHandler));
    embeddingsHandler.setResultFor(
        'search', Promise.resolve({result: {items: []}}));

    // Some of the tests below assume the query state is fully reset to empty
    // between tests.
    window.history.replaceState({}, '', '/');
    CrRouter.resetForTesting();
    element = document.createElement('history-app');
    document.body.appendChild(element);
    return flushTasks();
  });

  test('SetsScrollTarget', async () => {
    assertEquals(element.$.tabsScrollContainer, element.scrollTarget);

    // 'By group' view shares the same scroll container as default history view.
    element.$.router.selectedPage = 'grouped';
    await flushTasks();
    assertEquals(element.$.tabsScrollContainer, element.scrollTarget);

    // Switching to synced tabs should change scroll target to it.
    element.$.router.selectedPage = 'syncedTabs';
    await flushTasks();
    assertEquals(
        element.shadowRoot!.querySelector('history-synced-device-manager'),
        element.scrollTarget);
  });

  test('SetsScrollTargetForEmbeddingsDisabled', async () => {
    // Override loadTimeData and re-create the element to disable history
    // embeddings.
    loadTimeData.overrideValues({enableHistoryEmbeddings: false});
    element.remove();
    element = document.createElement('history-app');
    document.body.appendChild(element);
    await flushTasks();

    // By default, the history-list should be its own scroll container.
    assertEquals(
        element.shadowRoot!.querySelector('history-list'),
        element.scrollTarget);

    // 'By group' view switches the scroll target to it.
    element.$.router.selectedPage = 'grouped';
    await flushTasks();
    assertEquals(
        element.shadowRoot!.querySelector('history-clusters'),
        element.scrollTarget);

    // Switching to synced tabs should change scroll target to it.
    element.$.router.selectedPage = 'syncedTabs';
    await flushTasks();
    assertEquals(
        element.shadowRoot!.querySelector('history-synced-device-manager'),
        element.scrollTarget);
  });

  test('ShowsHistoryEmbeddings', async () => {
    // By default, embeddings should not even be in the DOM.
    assertFalse(!!element.shadowRoot!.querySelector('cr-history-embeddings'));

    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'one'}}));
    await flushTasks();
    assertFalse(!!element.shadowRoot!.querySelector('cr-history-embeddings'));

    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'two words'}}));
    await flushTasks();
    assertTrue(!!element.shadowRoot!.querySelector('cr-history-embeddings'));

    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'one'}}));
    await flushTasks();
    assertFalse(!!element.shadowRoot!.querySelector('cr-history-embeddings'));
  });

  test('SetsScrollOffset', async () => {
    function resizeAndWait(height: number) {
      const historyEmbeddingsContainer =
          element.shadowRoot!.querySelector<HTMLElement>(
              '#historyEmbeddingsContainer');
      assertTrue(!!historyEmbeddingsContainer);

      return new Promise<void>((resolve) => {
        const observer = new ResizeObserver(() => {
          if (historyEmbeddingsContainer.offsetHeight === height) {
            resolve();
            observer.unobserve(historyEmbeddingsContainer);
          }
        });
        observer.observe(historyEmbeddingsContainer);
        historyEmbeddingsContainer.style.height = `${height}px`;
      });
    }

    await resizeAndWait(700);
    await flushTasks();
    assertEquals(700, element.$.history.scrollOffset);

    await resizeAndWait(400);
    await flushTasks();
    assertEquals(400, element.$.history.scrollOffset);
  });

  test('QueriesMoreFromSiteFromHistoryEmbeddings', async () => {
    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'two words'}}));
    await flushTasks();
    const historyEmbeddings =
        element.shadowRoot!.querySelector('cr-history-embeddings');
    assertTrue(!!historyEmbeddings);

    const changeQueryEventPromise = eventToPromise('change-query', element);
    historyEmbeddings.dispatchEvent(new CustomEvent('more-from-site-click', {
      detail: {
        title: 'Google',
        url: {url: 'http://google.com'},
        urlForDisplay: 'google.com',
        relativeTime: '2 hours ago',
        sourcePassage: 'Google description',
        lastUrlVisitTimestamp: 1000,
      },
    }));
    const changeQueryEvent = await changeQueryEventPromise;
    assertEquals('host:google.com', changeQueryEvent.detail.search);
  });

  test('RemovesItemFromHistoryEmbeddings', async () => {
    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'two words'}}));
    await flushTasks();
    const historyEmbeddings =
        element.shadowRoot!.querySelector('cr-history-embeddings');
    assertTrue(!!historyEmbeddings);

    historyEmbeddings.dispatchEvent(new CustomEvent('remove-item-click', {
      detail: {
        title: 'Google',
        url: {url: 'http://google.com'},
        urlForDisplay: 'google.com',
        relativeTime: '2 hours ago',
        sourcePassage: 'Google description',
        lastUrlVisitTimestamp: 1000,
      },
    }));
    const removeVisitsArg = await browserService.whenCalled('removeVisits');
    assertEquals(1, removeVisitsArg.length);
    assertEquals('http://google.com', removeVisitsArg[0].url);
    assertEquals(1, removeVisitsArg[0].timestamps.length);
    assertEquals(1000, removeVisitsArg[0].timestamps[0]);
  });

  test('ChangesQueryStateWithFilterChips', async () => {
    const filterChips = element.shadowRoot!.querySelector(
        'cr-history-embeddings-filter-chips')!;
    const changeQueryEventPromise = eventToPromise('change-query', element);
    filterChips.dispatchEvent(new CustomEvent('selected-suggestion-changed', {
      detail: {
        value: {
          timeRangeStart: new Date('2011-01-01T00:00:00'),
        },
      },
      composed: true,
      bubbles: true,
    }));
    const changeQueryEvent = await changeQueryEventPromise;
    assertEquals('', changeQueryEvent.detail.search);
    assertEquals('2011-01-01', changeQueryEvent.detail.after);
  });

  test('UpdatesBindingsOnChangeQuery', async () => {
    // Change query to a multi-word search term and an after date.
    element.dispatchEvent(new CustomEvent('change-query', {
      bubbles: true,
      composed: true,
      detail: {
        search: 'two words',
        after: '2022-04-02',
      },
    }));
    await flushTasks();

    const expectedDateObject = new Date('2022-04-02T00:00:00');

    const filterChips = element.shadowRoot!.querySelector(
        'cr-history-embeddings-filter-chips')!;
    assertTrue(!!filterChips);
    assertEquals(
        expectedDateObject.getTime(), filterChips.timeRangeStart?.getTime());

    const historyEmbeddings =
        element.shadowRoot!.querySelector('cr-history-embeddings');
    assertTrue(!!historyEmbeddings);
    const timeRangeStartObj = historyEmbeddings.timeRangeStart;
    assertTrue(!!timeRangeStartObj);
    assertEquals(expectedDateObject.getTime(), timeRangeStartObj.getTime());

    // Update only the search term. Verify that the date object has not changed.
    element.dispatchEvent(new CustomEvent('change-query', {
      bubbles: true,
      composed: true,
      detail: {
        search: 'two words updated',
        after: '2022-04-02',
      },
    }));
    await flushTasks();
    assertEquals(timeRangeStartObj, historyEmbeddings.timeRangeStart);

    // Clear the after date query.
    element.dispatchEvent(new CustomEvent('change-query', {
      bubbles: true,
      composed: true,
      detail: {
        search: 'two words',
      },
    }));
    await flushTasks();
    assertEquals(undefined, historyEmbeddings.timeRangeStart);
  });

  test('UsesMinWordCount', async () => {
    loadTimeData.overrideValues({historyEmbeddingsSearchMinimumWordCount: 4});
    element.dispatchEvent(new CustomEvent('change-query', {
      bubbles: true,
      composed: true,
      detail: {search: 'two words'},
    }));
    await flushTasks();

    let historyEmbeddings =
        element.shadowRoot!.querySelector('cr-history-embeddings');
    assertFalse(!!historyEmbeddings);

    element.dispatchEvent(new CustomEvent('change-query', {
      bubbles: true,
      composed: true,
      detail: {search: 'at least four words'},
    }));
    await flushTasks();
    historyEmbeddings =
        element.shadowRoot!.querySelector('cr-history-embeddings');
    assertTrue(!!historyEmbeddings);
  });

  test('CountsCharacters', async () => {
    // Force cr-history-embeddings to be in the DOM for testing.
    loadTimeData.overrideValues({historyEmbeddingsSearchMinimumWordCount: 0});
    element.dispatchEvent(new CustomEvent(
        'change-query',
        {bubbles: true, composed: true, detail: {search: 'some fake input'}}));
    await flushTasks();

    function dispatchNativeInput(
        inputEvent: Partial<InputEvent>, inputValue: string) {
      element.$.toolbar.dispatchEvent(new CustomEvent(
          'search-term-native-before-input', {detail: {e: inputEvent}}));
      element.$.toolbar.dispatchEvent(
          new CustomEvent('search-term-native-input', {
            detail: {e: inputEvent, inputValue},
            composed: true,
            bubbles: true,
          }));
    }

    function getCount() {
      const historyEmbeddingsElement =
          element.shadowRoot!.querySelector('cr-history-embeddings')!;
      return historyEmbeddingsElement.numCharsForQuery;
    }

    dispatchNativeInput({data: 'a'}, 'a');
    assertEquals(1, getCount(), 'counts normal characters');
    dispatchNativeInput({data: 'b'}, 'ab');
    dispatchNativeInput({data: 'c'}, 'abc');
    assertEquals(3, getCount(), 'counts additional characters');

    dispatchNativeInput({data: 'pasted text'}, 'pasted text');
    assertEquals(1, getCount(), 'insert that replaces all text counts as 1');

    dispatchNativeInput({data: 'more text'}, 'pasted text more text');
    assertEquals(
        2, getCount(), 'insert that adds to existing input increments count');

    dispatchNativeInput({data: null}, 'pasted text more tex');
    assertEquals(3, getCount(), 'deletion increments');

    dispatchNativeInput({data: null}, '');
    assertEquals(0, getCount(), 'deletion of entire input resets counter');

    element.$.toolbar.dispatchEvent(new CustomEvent('search-term-cleared'));
    assertEquals(0, getCount(), 'resets on clear');
  });

  test('RegistersAndMaybeShowsPromo', async () => {
    assertDeepEquals(
        element.getSortedAnchorStatusesForTesting(),
        [
          ['kHistorySearchInputElementId', true],
        ],
    );
    await embeddingsHandler.whenCalled('maybeShowFeaturePromo');
  });

  test('ProductSpecsIncrementsToolbar', async () => {
    // Reset the app with product spec lists feature enabled.
    document.body.removeChild(element);
    loadTimeData.overrideValues({compareHistoryEnabled: true});
    element = document.createElement('history-app');
    document.body.appendChild(element);
    element.$.router.selectedPage = 'comparisonTables';
    await flushTasks();
    assertEquals(0, element.$.toolbar.count);

    const productSpecificationsList =
        element.shadowRoot!.querySelector('product-specifications-lists');
    assert(!!productSpecificationsList);

    // Mock adding a selected item.
    productSpecificationsList.selectedItems.add('uuid1');
    productSpecificationsList.dispatchEvent(
        new CustomEvent('product-spec-item-select', {
          bubbles: true,
          composed: true,
          detail: {
            checked: true,
            uuid: 'uuid1',
          },
        }));
    await flushTasks();

    assertEquals(1, element.$.toolbar.count);
  });

  test('ProductSpecsSelectUnselectAll', async () => {
    // Reset the app with product spec lists feature enabled.
    document.body.removeChild(element);
    loadTimeData.overrideValues({compareHistoryEnabled: true});
    element = document.createElement('history-app');
    document.body.appendChild(element);
    element.$.router.selectedPage = 'comparisonTables';
    await flushTasks();
    assertEquals(0, element.$.toolbar.count);

    // Stub the selectOrUnselectAll method in the list element.
    let selectAllCalled = false;
    const productSpecificationsList =
        element.shadowRoot!.querySelector('product-specifications-lists');
    assert(!!productSpecificationsList);
    productSpecificationsList.selectOrUnselectAll = function() {
      selectAllCalled = true;
    };

    // Mock ctrl+A.
    productSpecificationsList.selectedItems.add('uuid1');
    productSpecificationsList.selectedItems.add('uuid2');
    const modifier = isMac ? 'meta' : 'ctrl';
    pressAndReleaseKeyOn(document.body, 65, modifier, 'a');
    await flushTasks();

    assertEquals(true, selectAllCalled);
    assertEquals(2, element.$.toolbar.count);
  });

});